├── .coveragerc ├── .github └── workflows │ ├── style.yml │ └── tests.yml ├── .gitignore ├── AUTHORS ├── LICENSE ├── README.md ├── django_scopes ├── __init__.py ├── exceptions.py ├── forms.py ├── manager.py └── state.py ├── requirements_dev.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── settings.py ├── test_query.py └── testapp ├── __init__.py ├── apps.py ├── forms.py ├── migrations ├── 0001_initial.py ├── 0002_bookmark.py ├── 0003_commentgroup.py └── __init__.py ├── models.py ├── urls.py └── wsgi.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = django_scopes 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | def __str__ 8 | der __repr__ 9 | if settings.DEBUG 10 | NOQA 11 | NotImplementedError 12 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | flake8: 11 | name: flake8 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python 3.10 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: "3.10" 19 | - name: Install Dependencies 20 | run: python -m pip install -Ur requirements_dev.txt 21 | - name: Run flake8 22 | run: flake8 django_scopes tests 23 | isort: 24 | name: isort 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Set up Python 3.10 29 | uses: actions/setup-python@v1 30 | with: 31 | python-version: "3.10" 32 | - name: Install Dependencies 33 | run: python -m pip install -Ur requirements_dev.txt 34 | - name: Run isort 35 | run: isort --check -rc django_scopes tests 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | name: Tests 13 | strategy: 14 | matrix: 15 | python-version: [3.8, 3.9, '3.10', 3.11] 16 | django-version: [3.2, 4.0, 4.1, 4.2] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install Dependencies 24 | run: python -m pip install -Ur requirements_dev.txt 25 | - name: Install Django 26 | run: python -m pip install django==${{ matrix.django-version }} 27 | - name: Run tests 28 | run: coverage run -m pytest tests 29 | - name: Assure coverage 30 | run: coverage report --fail-under=100 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build/ 3 | dist/ 4 | *.egg-info 5 | env 6 | .idea/ 7 | *.sqlite3 8 | .cache 9 | .tox 10 | htmlcov/ 11 | *.pyc 12 | .coverage 13 | venv 14 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Here is an inevitably incomplete list of much-appreciated contributors -- 2 | people who have submitted patches, reported bugs, added translations, helped 3 | answer newbie questions, improved the documentation, and generally made this 4 | an awesome project. Thank you all! 5 | 6 | Raphael Michel 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-scopes 2 | ============= 3 | 4 | ![Build status](https://github.com/raphaelm/django-scopes/actions/workflows/tests.yml/badge.svg) 5 | ![PyPI](https://img.shields.io/pypi/v/django-scopes.svg) 6 | [![Python versions](https://img.shields.io/pypi/pyversions/django-scopes.svg)](https://pypi.org/project/django-scopes/) 7 | ![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-scopes) 8 | 9 | Motivation 10 | ---------- 11 | 12 | Many of us use Django to build multi-tenant applications where every user only ever 13 | gets access to a small, separated fraction of the data in our application, while 14 | at the same time having *some* global functionality that makes separate databases per 15 | client infeasible. While Django does a great job protecting us from building SQL 16 | injection vulnerabilities and similar errors, Django can't protect us from logic 17 | errors and one of the most dangerous types of security issues for multi-tenant 18 | applications is that we leak data across tenants. 19 | 20 | It's so easy to forget that one ``.filter`` call and it's hard to catch these errors 21 | in both manual and automated testing, since you usually do not have a lot of clients 22 | in your development setup. Leaving [radical, database-dependent ideas](https://github.com/bernardopires/django-tenant-schemas) 23 | aside, there aren't many approaches available in the ecosystem to prevent these mistakes 24 | from happening aside from rigorous code review. 25 | 26 | We'd like to propose this module as a flexible line of defense. It is meant to have 27 | little impact on your day-to-day work, but act as a safeguard in case you build a 28 | faulty query. 29 | 30 | Installation 31 | ------------ 32 | 33 | There's nothing required apart from a simple 34 | 35 | pip install django-scopes 36 | 37 | Compatibility 38 | ------------- 39 | 40 | This library is tested against **Python 3.8-3.11** and **Django 3.2-4.2**. 41 | 42 | Usage 43 | ----- 44 | 45 | Let's assume we have a multi-tenant blog application consisting of the three models ``Site``, 46 | ``Post``, and ``Comment``: 47 | 48 | ```python 49 | from django.db import models 50 | 51 | class Site(models.Model): 52 | name = models.CharField(…) 53 | 54 | class Post(models.Model): 55 | site = models.ForeignKey(Site, …) 56 | title = models.CharField(…) 57 | 58 | class Comment(models.Model): 59 | post = models.ForeignKey(Post, …) 60 | text = models.CharField(…) 61 | ``` 62 | 63 | In this case, our model `Site` acts as the tenant for the blog posts and their comments, hence 64 | our application will probably be full of statements like 65 | ``Post.objects.filter(site=current_site)``, ``Comment.objects.filter(post__site=current_site)``, 66 | or more complex when more flexible permission handling is involved. With **django-scopes**, we 67 | encourage you to still write these queries with your custom permission-based filters, but 68 | we add a custom model manager that has knowledge about posts and comments being part of a 69 | tenant scope: 70 | 71 | ```python 72 | from django_scopes import ScopedManager 73 | 74 | class Post(models.Model): 75 | site = models.ForeignKey(Site, …) 76 | title = models.CharField(…) 77 | 78 | objects = ScopedManager(site='site') 79 | 80 | class Comment(models.Model): 81 | post = models.ForeignKey(Post, …) 82 | text = models.CharField(…) 83 | 84 | objects = ScopedManager(site='post__site') 85 | ``` 86 | 87 | The keyword argument ``site`` defines the name of our **scope dimension**, while the string 88 | ``'site'`` or ``'post__site'`` tells us how we can look up the value for this scope dimension 89 | in ORM queries. 90 | 91 | You could have multi-dimensional scopes by passing multiple keyword arguments to 92 | ``ScopedManager``, e.g. ``ScopedManager(site='post__site', user='author')`` if that is 93 | relevant to your usecase. 94 | 95 | Now, with this custom manager, all queries are banned at first: 96 | 97 | >>> Comment.objects.all() 98 | ScopeError: A scope on dimension "site" needs to be active for this query. 99 | 100 | The only thing that will work is ``Comment.objects.none()``, which is useful e.g. for Django 101 | generic view definitions. 102 | 103 | ### Activate scopes in contexts 104 | 105 | You can now use our context manager to specifically allow queries to a specific blogging site, 106 | e.g.: 107 | 108 | ```python 109 | from django_scopes import scope 110 | 111 | with scope(site=current_site): 112 | Comment.objects.all() 113 | ``` 114 | 115 | This will *automatically* add a ``.filter(post__site=current_site)`` to all of your queries. 116 | Again, we recommend that you *still* write them explicitly, but it is nice to know to have a 117 | safeguard. 118 | 119 | Of course, you can still explicitly enter a non-scoped context to access all the objects in your 120 | system: 121 | 122 | ```python 123 | with scope(site=None): 124 | Comment.objects.all() 125 | ``` 126 | 127 | This also works correctly nested within a previously defined scope. You can also activate multiple 128 | values at once: 129 | 130 | ```python 131 | with scope(site=[site1, site2]): 132 | Comment.objects.all() 133 | ``` 134 | 135 | Sounds cumbersome to put those ``with`` statements everywhere? Maybe not at all: You probably 136 | already have a middleware that determines the site (or tenant, in general) for every request 137 | based on URL or logged in user, and you can easily use it there to just automatically wrap 138 | it around all your tenant-specific views. 139 | 140 | Functions can opt out of this behavior by using 141 | 142 | ```python 143 | from django_scopes import scopes_disabled 144 | 145 | 146 | with scopes_disabled(): 147 | … 148 | 149 | # OR 150 | 151 | @scopes_disabled() 152 | def fun(…): 153 | … 154 | ``` 155 | 156 | Please note that **django-scopes** is also active during migrations, so if you are writing a 157 | data migration – or have written one in the past! – you'll have to add appropriate scoping 158 | or use the ``scopes_disabled`` context. 159 | 160 | ### Custom manager classes 161 | 162 | If you were already using a custom manager class, you can pass it to a `ScopedManager` with the `_manager_class` 163 | keyword like this: 164 | from django.db import models 165 | 166 | ```python 167 | from django.db import models 168 | 169 | class SiteManager(models.Manager): 170 | 171 | def get_queryset(self): 172 | return super().get_queryset().exclude(name__startswith='test') 173 | 174 | class Site(models.Model): 175 | name = models.CharField(…) 176 | 177 | objects = ScopedManager(site='site', _manager_class=SiteManager) 178 | ``` 179 | 180 | 181 | ### Scoping the User model 182 | 183 | Assume you've got two models `User` and `Post`. Using the examples above, you can ensure that users only ever see their own diary posts. But how about leaking other users to the currently logged in user? If you application doesn't have much (or any) interaction between users, you can scope the user model. Please note that you'll need a [custom user model](https://docs.djangoproject.com/en/dev/topics/auth/customizing/#specifying-a-custom-user-model). Which base classes your user and manager work off will very between projects. 184 | 185 | ```python 186 | class User(AbstractUser): 187 | objects = ScopedManager(user='pk', _manager_class=UserManager) 188 | 189 | # (...) 190 | ``` 191 | 192 | Activating the scope comes with a little caveat - you need to use the users primary key, not the whole object: 193 | 194 | ```python 195 | with scope(user=request.user.pk): 196 | # do something :) 197 | ``` 198 | 199 | Caveats 200 | ------- 201 | 202 | ### Locking 203 | 204 | With django-scopes, a seemingly innocent query like 205 | 206 | ```python 207 | Comment.objects.select_for_update().get(pk=3) 208 | ``` 209 | 210 | could cause unexpected locking across your database, since django-scopes will auto-add one or more ``JOIN`` statements to the query, and joined tables will **also be locked**. 211 | One possible fix is of course using ``scopes_disabled()``, around this query. 212 | On most modern databases, there's also a way to specify explicitly which tables you want locked: 213 | 214 | ```python 215 | Comment.objects.select_for_update(of=("self",)).get(pk=3) 216 | ``` 217 | 218 | You can check if your database supports this feature at runtime using ``connection.features.has_select_for_update_of``. 219 | 220 | ### Admin 221 | 222 | **django-scopes** is not compatible with the django admin out of the box, integration requires a 223 | custom middleware. (If you write one, please open a PR to include it in this package!) 224 | 225 | ### Testing 226 | 227 | We want to enforce scoping by default to stay safe, which unfortunately 228 | breaks the Django test runner as well as pytest-django. For now, we haven't found 229 | a better solution than to monkeypatch it: 230 | 231 | ```python 232 | from django.test import utils 233 | from django_scopes import scopes_disabled 234 | 235 | utils.setup_databases = scopes_disabled()(utils.setup_databases) 236 | ``` 237 | 238 | You can wrap many of your test and fixtures inside ``scopes_disabled()`` as well, but we wouldn't advise to do it with all of them: Especially when writing higher-level functional tests, such as tests using Django's test client or tests testing celery tasks, you should make sure that your application code runs as it does in production. Therefore, writing tests for a project using django-scopes often looks like this: 239 | 240 | ```python 241 | @pytest.mark.django_db 242 | def test_a_view(client): 243 | with scopes_disabled(): 244 | u = User.objects.create(...) 245 | client.post('/user/{}/delete'.format(u.pk)) 246 | with scopes_disabled(): 247 | assert not User.objects.filter(pk=u.pk).exists() 248 | ``` 249 | 250 | If you want to disable scoping or activate a certain scope whenever a specific fixture is used, you can do so in py.test like this: 251 | 252 | ```python 253 | @pytest.fixture 254 | def site(): 255 | s = Site.objects.create(...) 256 | with scope(site=s): 257 | yield s 258 | ``` 259 | 260 | When trying to port a project with *lots* of fixtures, it can be helpful to roll a small py.test plugin in your ``conftest.py`` to just globally disable scoping for all fixtures which are not yielding fixtures (like the one above): 261 | 262 | ```python 263 | @pytest.hookimpl(hookwrapper=True) 264 | def pytest_fixture_setup(fixturedef, request): 265 | if inspect.isgeneratorfunction(fixturedef.func): 266 | yield 267 | else: 268 | with scopes_disabled(): 269 | yield 270 | ``` 271 | 272 | ### ModelForms 273 | 274 | When using model forms, Django will automatically generate choice fields on foreign 275 | keys and many-to-many fields. This won't work here, so we supply helper field 276 | classes ``SafeModelChoiceField`` and ``SafeModelMultipleChoiceField`` that use an 277 | empty queryset instead: 278 | 279 | ```python 280 | from django.forms import ModelForm 281 | from django_scopes.forms import SafeModelChoiceField 282 | 283 | class PostMethodForm(ModelForm): 284 | class Meta: 285 | model = Comment 286 | field_classes = { 287 | 'post': SafeModelChoiceField, 288 | } 289 | ``` 290 | 291 | ### django-filter 292 | 293 | We noticed that ``django-filter`` also runs some queries when generating filtersets. 294 | Currently, our best workaround is this: 295 | 296 | ```python 297 | from django_scopes import scopes_disabled 298 | 299 | with scopes_disabled(): 300 | class CommentFilter(FilterSet): 301 | … 302 | ``` 303 | 304 | ### Uniqueness 305 | 306 | One subtle class of bug that can be introduced by adding django-scopes to your project is if you try to generate unique identifiers in your database with a pattern like this: 307 | 308 | ```python 309 | 310 | def generate_unique_value(): 311 | while True: 312 | key = _generate_random_key() 313 | if not Model.objects.filter(key=key).exists(): 314 | return key 315 | ``` 316 | 317 | If you want keys to be unique across tenants, make sure to wrap such functions with ``scopes_disabled()``! 318 | 319 | When using a [ModelForm](https://docs.djangoproject.com/en/dev/topics/forms/modelforms/) (or [class based view](https://docs.djangoproject.com/en/dev/topics/class-based-views/)) to create or update a model, unexpected IntegrityErrors may occur. ModelForms perform a uniqueness check before actually saving the model. If that check runs in a scoped context, it cannot find conflicting instances, leading to an IntegrityErrors once the actual `.save()` happens. To combat this, wrap the call in ``scopes_disabled()``. 320 | 321 | ```python 322 | class Site(models.Model): 323 | name = models.CharField(unique=True, …) 324 | 325 | # (...) 326 | 327 | def validate_unique(self, *args, **kwargs): 328 | with scopes_disabled(): 329 | super().validate_unique(*args, **kwargs) 330 | ``` 331 | 332 | ## Security 333 | 334 | If you discover a security issue, please contact us at security@pretix.eu and see our [Responsible Disclosure Policy](https://docs.pretix.eu/trust/security/disclosure/) further information. 335 | 336 | ## Further reading 337 | 338 | If you'd like to read more about the practical use of django-scopes, there is a [blog 339 | post](https://behind.pretix.eu/2019/06/17/scopes/) about its introduction in the [pretix](https://pretix.eu) project. 340 | 341 | [Here](https://rixx.de/blog/using-the-django-shell-with-django-scopes/) is a guide on how to write a ``shell_scoped`` 342 | django-admin command to provide a scoped Django shell. 343 | -------------------------------------------------------------------------------- /django_scopes/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import ScopeError 2 | from .manager import ScopedManager 3 | from .state import get_scope, scope, scopes_disabled 4 | 5 | version = '2.0.0' 6 | 7 | __all__ = [ 8 | 'version', 9 | 'ScopeError', 10 | 'ScopedManager', 11 | 'scope', 12 | 'get_scope', 13 | 'scopes_disabled' 14 | ] 15 | -------------------------------------------------------------------------------- /django_scopes/exceptions.py: -------------------------------------------------------------------------------- 1 | class ScopeError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /django_scopes/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import ModelChoiceField, ModelMultipleChoiceField 2 | 3 | 4 | class SafeModelChoiceField(ModelChoiceField): 5 | def __init__(self, queryset, **kwargs): 6 | queryset = queryset.model.objects.none() 7 | super().__init__(queryset, **kwargs) 8 | 9 | 10 | class SafeModelMultipleChoiceField(ModelMultipleChoiceField): 11 | def __init__(self, queryset, **kwargs): 12 | queryset = queryset.model.objects.none() 13 | super().__init__(queryset, **kwargs) 14 | -------------------------------------------------------------------------------- /django_scopes/manager.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from .exceptions import ScopeError 4 | from .state import get_scope 5 | 6 | 7 | class DisabledQuerySet(models.QuerySet): 8 | 9 | def __init__(self, *args, **kwargs): 10 | self.missing_scopes = kwargs.pop('missing_scopes', None) 11 | super().__init__(*args, **kwargs) 12 | 13 | def error(self, *args, **kwargs): 14 | raise ScopeError("A scope on dimension(s) {} needs to be active for this query.".format( 15 | ', '.join(self.missing_scopes) 16 | )) 17 | 18 | def _clone(self): 19 | c = super()._clone() 20 | c.missing_scopes = self.missing_scopes 21 | return c 22 | 23 | def none(self): 24 | c = models.QuerySet(model=self.model, query=self.query.chain(), using=self._db, hints=self._hints) 25 | c._sticky_filter = self._sticky_filter 26 | c._for_write = self._for_write 27 | c._prefetch_related_lookups = self._prefetch_related_lookups[:] 28 | c._known_related_objects = self._known_related_objects 29 | c._iterable_class = self._iterable_class 30 | c._fields = self._fields 31 | return c.none() 32 | 33 | # We protect disable everything except for .using(), .none() and .create() 34 | __bool__ = error 35 | __getitem__ = error 36 | __iter__ = error 37 | __len__ = error 38 | __erpr__ = error 39 | all = error 40 | aggregate = error 41 | annotate = error 42 | count = error 43 | earliest = error 44 | complex_filter = error 45 | select_for_update = error 46 | filter = error 47 | first = error 48 | get = error 49 | get_or_create = error 50 | update_or_create = error 51 | delete = error 52 | dates = error 53 | datetimes = error 54 | iterator = error 55 | last = error 56 | latest = error 57 | only = error 58 | order_by = error 59 | reverse = error 60 | union = error 61 | update = error 62 | raw = error 63 | values = error 64 | values_list = error 65 | 66 | 67 | def ScopedManager(_manager_class=models.Manager, **scopes): 68 | required_scopes = set(scopes.keys()) 69 | 70 | class Manager(_manager_class): 71 | def __init__(self): 72 | super().__init__() 73 | 74 | def get_queryset(self): 75 | current_scope = get_scope() 76 | if not current_scope.get('_enabled', True): 77 | return super().get_queryset() 78 | missing_scopes = required_scopes - set(current_scope.keys()) 79 | if missing_scopes: 80 | return DisabledQuerySet(self.model, using=self._db, missing_scopes=missing_scopes) 81 | else: 82 | filter_kwargs = {} 83 | for dimension in required_scopes: 84 | current_value = current_scope[dimension] 85 | if isinstance(current_value, (list, tuple)): 86 | filter_kwargs[scopes[dimension] + '__in'] = current_value 87 | elif current_value is not None: 88 | filter_kwargs[scopes[dimension]] = current_value 89 | return super().get_queryset().filter(**filter_kwargs) 90 | 91 | def all(self): 92 | a = super().all() 93 | if isinstance(a, DisabledQuerySet): 94 | a = a.all() 95 | return a 96 | 97 | return Manager() 98 | -------------------------------------------------------------------------------- /django_scopes/state.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from contextvars import ContextVar 3 | from typing import Optional 4 | 5 | state: ContextVar[Optional[dict]] = ContextVar('state', default=None) 6 | 7 | 8 | @contextmanager 9 | def scopes_disabled(): 10 | with scope(_enabled=False): 11 | yield 12 | 13 | 14 | @contextmanager 15 | def scope(**scope_kwargs): 16 | previous_scope = state.get() 17 | if previous_scope is None: 18 | previous_scope = {} 19 | state.set(previous_scope) 20 | 21 | new_scope = dict(previous_scope) 22 | new_scope['_enabled'] = True 23 | new_scope.update(scope_kwargs) 24 | state.set(new_scope) 25 | try: 26 | yield 27 | finally: 28 | state.set(previous_scope) 29 | 30 | 31 | def get_scope(): 32 | return state.get() or {} 33 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-django 3 | coverage 4 | codecov 5 | isort==5.10.1 6 | flake8 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | exclude = migrations,.ropeproject,settings.py,conftest.py 4 | max-complexity = 11 5 | 6 | [isort] 7 | combine_as_imports = true 8 | default_section = THIRDPARTY 9 | include_trailing_comma = true 10 | known_first_party = django_scopes 11 | multi_line_output = 5 12 | skip = settings.py,conftest.py 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | from os import path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | here = path.abspath(path.dirname(__file__)) 7 | 8 | with open('README.md') as fh: 9 | long_description = fh.read() 10 | 11 | try: 12 | from django_scopes import version 13 | except ImportError: 14 | version = '?' 15 | 16 | setup( 17 | name='django-scopes', 18 | version=version, 19 | description='Scope querys in multi-tenant django applications', 20 | long_description=long_description, 21 | long_description_content_type='text/markdown', 22 | url='https://github.com/raphaelm/django-scopes', 23 | author='Raphael Michel', 24 | author_email='mail@raphaelmichel.de', 25 | license='Apache License 2.0', 26 | classifiers=[ 27 | "Development Status :: 5 - Production/Stable", 28 | 'Intended Audience :: Developers', 29 | 'Intended Audience :: Other Audience', 30 | 'License :: OSI Approved :: Apache Software License', 31 | 'Programming Language :: Python :: 3.8', 32 | 'Programming Language :: Python :: 3.9', 33 | 'Programming Language :: Python :: 3.10', 34 | 'Programming Language :: Python :: 3.11', 35 | "Framework :: Django", 36 | 'Framework :: Django :: 3.2', 37 | 'Framework :: Django :: 4.0', 38 | 'Framework :: Django :: 4.1', 39 | 'Framework :: Django :: 4.2', 40 | ], 41 | keywords='json database models', 42 | install_requires=["Django>=3.2"], 43 | packages=find_packages(exclude=['tests']), 44 | include_package_data=True, 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/django-scopes/a846a2d7e44ed13967377227cac6059462b2be60/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.test import utils 3 | 4 | from django_scopes import scopes_disabled 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | import django 9 | 10 | django.setup() 11 | 12 | utils.setup_databases = scopes_disabled()(utils.setup_databases) 13 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | SECRET_KEY = 'kk0ai8i0dm-8^%&0&+e-rsmk8#t&)6r*y!wh=xx7l12+6k5mg4' 8 | 9 | DEBUG = True 10 | ALLOWED_HOSTS = ['*'] 11 | 12 | 13 | INSTALLED_APPS = [ 14 | 'django.contrib.admin', 15 | 'django.contrib.auth', 16 | 'django.contrib.contenttypes', 17 | 'django.contrib.sessions', 18 | 'django.contrib.messages', 19 | 'django.contrib.staticfiles', 20 | 'tests.testapp' 21 | ] 22 | 23 | MIDDLEWARE = [ 24 | 'django.middleware.security.SecurityMiddleware', 25 | 'django.contrib.sessions.middleware.SessionMiddleware', 26 | 'django.middleware.common.CommonMiddleware', 27 | 'django.middleware.csrf.CsrfViewMiddleware', 28 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 29 | 'django.contrib.messages.middleware.MessageMiddleware', 30 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 31 | ] 32 | 33 | ROOT_URLCONF = 'tests.testapp.urls' 34 | 35 | TEMPLATES = [ 36 | { 37 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 38 | 'DIRS': [], 39 | 'APP_DIRS': True, 40 | 'OPTIONS': { 41 | 'context_processors': [ 42 | 'django.template.context_processors.debug', 43 | 'django.template.context_processors.request', 44 | 'django.contrib.auth.context_processors.auth', 45 | 'django.contrib.messages.context_processors.messages', 46 | ], 47 | }, 48 | }, 49 | ] 50 | 51 | WSGI_APPLICATION = 'tests.testapp.wsgi.application' 52 | 53 | DATABASES = { 54 | 'default': { 55 | 'ENGINE': 'django.db.backends.sqlite3', 56 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 57 | } 58 | } 59 | 60 | STATIC_URL = '/static/' 61 | 62 | LANGUAGE_CODE = 'en' 63 | TIME_ZONE = 'UTC' 64 | USE_I18N = True 65 | USE_L10N = True 66 | USE_TZ = True 67 | 68 | LANGUAGES = [ 69 | ('de', _('German')), 70 | ('en', _('English')), 71 | ('fr', _('French')), 72 | ] 73 | 74 | 75 | LOGGING = { 76 | 'version': 1, 77 | 'filters': { 78 | 'require_debug_true': { 79 | '()': 'django.utils.log.RequireDebugTrue', 80 | } 81 | }, 82 | 'handlers': { 83 | 'console': { 84 | 'level': 'DEBUG', 85 | 'filters': ['require_debug_true'], 86 | 'class': 'logging.StreamHandler', 87 | } 88 | }, 89 | 'loggers': { 90 | 'django.db.backends': { 91 | 'level': 'DEBUG', 92 | 'handlers': ['console'], 93 | } 94 | } 95 | } 96 | 97 | 98 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db.models import IntegerField, Value 3 | 4 | from django_scopes import ScopeError, get_scope, scope, scopes_disabled 5 | 6 | from .testapp.forms import CommentForm, CommentGroupForm 7 | from .testapp.models import Bookmark, Comment, CommentGroup, Post, Site 8 | 9 | 10 | @pytest.fixture 11 | def site1(): 12 | return Site.objects.create(name="Ceasar's blog") 13 | 14 | 15 | @pytest.fixture 16 | def site2(): 17 | return Site.objects.create(name="Augustus' blog") 18 | 19 | 20 | @pytest.fixture 21 | def post1(site1): 22 | return Post.objects.create(site=site1, title="I was stabbed") 23 | 24 | 25 | @pytest.fixture 26 | def post2(site2): 27 | return Post.objects.create(site=site2, title="I'm in power now!") 28 | 29 | 30 | @pytest.fixture 31 | def deleted_post(site1): 32 | return Post.objects.create(site=site1, title="deleted") 33 | 34 | 35 | @pytest.fixture 36 | def comment1(post1): 37 | return Comment.objects.create(post=post1, text="I'm so sorry.") 38 | 39 | 40 | @pytest.fixture 41 | def comment2(post2): 42 | return Comment.objects.create(post=post2, text="Cheers!") 43 | 44 | 45 | @pytest.fixture 46 | def commentgroup(site1, comment1, comment2): 47 | group = CommentGroup.objects.create(site=site1) 48 | with scopes_disabled(): 49 | group.comments.set([comment1, comment2]) 50 | return group 51 | 52 | 53 | @pytest.mark.django_db 54 | def test_allow_unaffected_models(site1, site2): 55 | assert list(Site.objects.all()) == [site1, site2] 56 | 57 | 58 | @pytest.mark.django_db 59 | def test_require_scope(): 60 | with pytest.raises(ScopeError): 61 | Post.objects.count() 62 | with pytest.raises(ScopeError): 63 | Post.objects.all() 64 | 65 | 66 | @pytest.mark.django_db 67 | def test_none_works(): 68 | assert list(Post.objects.none()) == [] 69 | assert list(Post.objects.none().all()) == [] 70 | assert list(Post.objects.none().filter()) == [] 71 | 72 | 73 | @pytest.mark.django_db 74 | def test_require_scope_survive_clone(): 75 | Post.objects.using('default') 76 | with pytest.raises(ScopeError): 77 | Post.objects.using('default').all() 78 | with pytest.raises(ScopeError): 79 | Post.objects.all().all().all().count() 80 | 81 | 82 | @pytest.mark.django_db 83 | def test_require_scope_iterate(): 84 | q = Post.objects.using('default') 85 | with pytest.raises(ScopeError): 86 | list(Post.objects.using('default')) 87 | 88 | with pytest.raises(ScopeError): 89 | with scope(site=site1): 90 | assert list(q) == [post1] 91 | 92 | 93 | @pytest.mark.django_db 94 | def test_require_scope_at_definition_not_evaluation(site1, post1, post2): 95 | with pytest.raises(ScopeError): 96 | Post.objects.all() 97 | 98 | with scope(site=site1): 99 | p = Post.objects.all() 100 | assert list(p) == [post1] 101 | 102 | 103 | @pytest.mark.django_db 104 | def test_scope_add_filter(site1, site2, post1, post2): 105 | with pytest.raises(ScopeError): 106 | Post.objects.all() 107 | 108 | with scope(site=site1): 109 | assert get_scope() == {'site': site1, '_enabled': True} 110 | assert list(Post.objects.all()) == [post1] 111 | with scope(site=site2): 112 | assert get_scope() == {'site': site2, '_enabled': True} 113 | assert list(Post.objects.all()) == [post2] 114 | 115 | 116 | @pytest.mark.django_db 117 | def test_scope_keep_filter(site1, site2, post1, post2): 118 | with pytest.raises(ScopeError): 119 | Post.objects.all() 120 | 121 | with scope(site=site1): 122 | assert list(Post.objects.annotate(c=Value(3, output_field=IntegerField())).distinct().all()) == [post1] 123 | with scope(site=site2): 124 | assert list(Post.objects.annotate(c=Value(3, output_field=IntegerField())).distinct().all()) == [post2] 125 | 126 | 127 | @pytest.mark.django_db 128 | def test_scope_advanced_lookup(site1, site2, comment1, comment2): 129 | with pytest.raises(ScopeError): 130 | Post.objects.all() 131 | 132 | with scope(site=site1): 133 | assert list(Comment.objects.all()) == [comment1] 134 | with scope(site=site2): 135 | assert list(Comment.objects.all()) == [comment2] 136 | 137 | 138 | @pytest.mark.django_db 139 | def test_scope_nested(site1, site2, comment1, comment2): 140 | with pytest.raises(ScopeError): 141 | Post.objects.all() 142 | 143 | with scope(site=site1): 144 | assert list(Comment.objects.all()) == [comment1] 145 | assert get_scope() == {'site': site1, '_enabled': True} 146 | with scope(site=site2): 147 | assert get_scope() == {'site': site2, '_enabled': True} 148 | assert list(Comment.objects.all()) == [comment2] 149 | assert get_scope() == {'site': site1, '_enabled': True} 150 | assert list(Comment.objects.all()) == [comment1] 151 | 152 | 153 | @pytest.mark.django_db 154 | def test_scope_multisite(site1, site2, comment1, comment2): 155 | with scope(site=[site1]): 156 | assert list(Comment.objects.all()) == [comment1] 157 | with scope(site=[site1, site2]): 158 | assert list(Comment.objects.all()) == [comment1, comment2] 159 | assert get_scope() == {'site': [site1, site2], '_enabled': True} 160 | 161 | 162 | @pytest.mark.django_db 163 | def test_scope_uses_manager_class(site1, post1, deleted_post): 164 | with scope(site=site1): 165 | assert deleted_post not in site1.post_set.all() 166 | 167 | 168 | @pytest.mark.django_db 169 | def test_scope_as_decorator(site1, site2, comment1, comment2): 170 | @scope(site=site1) 171 | def inner(): 172 | assert list(Comment.objects.all()) == [comment1] 173 | 174 | inner() 175 | 176 | 177 | @pytest.mark.django_db 178 | def test_scope_opt_out(site1, site2, comment1, comment2): 179 | with scopes_disabled(): 180 | assert get_scope() == {'_enabled': False} 181 | assert list(Comment.objects.all()) == [comment1, comment2] 182 | 183 | 184 | @pytest.mark.django_db 185 | def test_scope_opt_out_decorator(site1, site2, comment1, comment2): 186 | @scopes_disabled() 187 | def inner(): 188 | assert list(Comment.objects.all()) == [comment1, comment2] 189 | 190 | inner() 191 | 192 | 193 | @pytest.fixture 194 | def bm1_1(post1): 195 | return Bookmark.objects.create(post=post1, userid=1) 196 | 197 | 198 | @pytest.fixture 199 | def bm1_2(post1): 200 | return Bookmark.objects.create(post=post1, userid=2) 201 | 202 | 203 | @pytest.fixture 204 | def bm2_1(post2): 205 | return Bookmark.objects.create(post=post2, userid=1) 206 | 207 | 208 | @pytest.mark.django_db 209 | def test_multiple_dimensions(site1, site2, bm2_1, bm1_1, bm1_2): 210 | with pytest.raises(ScopeError): 211 | Bookmark.objects.all() 212 | 213 | with scope(site=site1): 214 | with pytest.raises(ScopeError): 215 | Bookmark.objects.all() 216 | 217 | with scope(user_id=1): 218 | with pytest.raises(ScopeError): 219 | Bookmark.objects.all() 220 | 221 | with scope(site=site1): 222 | with scope(user_id=1): 223 | assert list(Bookmark.objects.all()) == [bm1_1] 224 | with scope(site=site2): 225 | assert list(Bookmark.objects.all()) == [bm2_1] 226 | assert list(Bookmark.objects.all()) == [bm1_1] 227 | 228 | with scope(user_id=2): 229 | assert list(Bookmark.objects.all()) == [bm1_2] 230 | 231 | with scope(site=site1, user_id=1): 232 | assert list(Bookmark.objects.all()) == [bm1_1] 233 | 234 | 235 | @pytest.mark.django_db 236 | def test_forms_require_scope(comment1, commentgroup): 237 | # honestly, importing the forms is the main test, because that's what usually breaks 238 | with pytest.raises(ScopeError): 239 | assert CommentForm(instance=comment1) 240 | assert CommentGroupForm(instance=commentgroup) 241 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/django-scopes/a846a2d7e44ed13967377227cac6059462b2be60/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestappConfig(AppConfig): 5 | name = 'tests.testapp' 6 | -------------------------------------------------------------------------------- /tests/testapp/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from django_scopes.forms import ( 4 | SafeModelChoiceField, SafeModelMultipleChoiceField, 5 | ) 6 | 7 | from .models import Comment, CommentGroup 8 | 9 | 10 | class CommentForm(forms.ModelForm): 11 | class Meta: 12 | model = Comment 13 | fields = ("post", ) 14 | field_classes = { 15 | "post": SafeModelChoiceField, 16 | } 17 | 18 | 19 | class CommentGroupForm(forms.ModelForm): 20 | class Meta: 21 | model = CommentGroup 22 | fields = ("comments", ) 23 | field_classes = { 24 | "comments": SafeModelMultipleChoiceField, 25 | } 26 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-10 12:52 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Site', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=200)), 20 | ], 21 | ), 22 | migrations.CreateModel( 23 | name='Post', 24 | fields=[ 25 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 26 | ('title', models.CharField(max_length=200)), 27 | ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testapp.Site')), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name='Comment', 32 | fields=[ 33 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('text', models.TextField()), 35 | ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testapp.Post')), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0002_bookmark.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.1 on 2019-05-12 12:10 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('testapp', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Bookmark', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('userid', models.IntegerField()), 19 | ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testapp.Post')), 20 | ], 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0003_commentgroup.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-04-07 08:45 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("testapp", "0002_bookmark"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="CommentGroup", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("comments", models.ManyToManyField(to="testapp.comment")), 27 | ( 28 | "site", 29 | models.ForeignKey( 30 | on_delete=django.db.models.deletion.CASCADE, to="testapp.site" 31 | ), 32 | ), 33 | ], 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /tests/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/django-scopes/a846a2d7e44ed13967377227cac6059462b2be60/tests/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_scopes import ScopedManager 4 | 5 | 6 | class PostManager(models.Manager): 7 | 8 | def get_queryset(self): 9 | return super().get_queryset().exclude(title='deleted') 10 | 11 | 12 | class Site(models.Model): 13 | name = models.CharField(max_length=200) 14 | 15 | def __str__(self): 16 | return str(self.name) 17 | 18 | 19 | class Post(models.Model): 20 | site = models.ForeignKey(Site, on_delete=models.CASCADE) 21 | title = models.CharField(max_length=200) 22 | 23 | objects = ScopedManager(site='site', _manager_class=PostManager) 24 | 25 | 26 | class Comment(models.Model): 27 | post = models.ForeignKey(Post, on_delete=models.CASCADE) 28 | text = models.TextField() 29 | 30 | objects = ScopedManager(site='post__site') 31 | 32 | 33 | class Bookmark(models.Model): 34 | post = models.ForeignKey(Post, on_delete=models.CASCADE) 35 | userid = models.IntegerField() 36 | 37 | objects = ScopedManager(site='post__site', user_id='userid') 38 | 39 | 40 | class CommentGroup(models.Model): 41 | """ Contrived many-to-many example """ 42 | site = models.ForeignKey(Site, on_delete=models.CASCADE) 43 | comments = models.ManyToManyField(Comment) 44 | 45 | objects = ScopedManager(site='site') 46 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [ 2 | ] 3 | -------------------------------------------------------------------------------- /tests/testapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for demoproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------