├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── codeql.yml │ └── main.yml ├── .gitignore ├── .markdownlint.json ├── .pre-commit-config.yaml ├── .sonarcloud.properties ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── django_tenants_url ├── __init__.py ├── apps.py ├── handlers.py ├── middleware.py ├── models.py ├── permissions.py ├── serializers.py ├── settings.py ├── urls.py ├── utils.py └── views.py ├── docs ├── example.md ├── handlers.md ├── index.md ├── middleware.md ├── models.md ├── permissions.md ├── releases.md ├── serializers.md ├── settings.md ├── urls.md ├── utils.md └── views.md ├── dtu_test_project ├── Makefile ├── customers │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── factories.py │ │ └── test_models.py │ └── views.py ├── docker-compose.yml ├── dtu_test │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── testing │ │ ├── __init__.py │ │ ├── databases.py │ │ └── settings.py │ ├── urls.py │ └── wsgi.py ├── dtu_test_app │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── tests │ │ ├── __init__.py │ │ ├── factories.py │ │ ├── test_models.py │ │ └── test_views.py │ └── views.py ├── manage.py ├── pytest.ini └── requirements.txt ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt ├── setup.py └── unittest.yml /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | ; Top-level editorconfig file for this project. 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false 13 | 14 | # See Google Shell Style Guide 15 | # https://google.github.io/styleguide/shell.xml 16 | [*.sh] 17 | indent_size = 2 # shfmt: like -i 2 18 | insert_final_newline = true 19 | switch_case_indent = true # shfmt: like -ci 20 | 21 | [*.py] 22 | insert_final_newline = true 23 | indent_size = 4 24 | max_line_length = 120 25 | 26 | [*.md] 27 | trim_trailing_whitespace=false 28 | 29 | [Makefile] 30 | indent_style = tab 31 | 32 | [*.js] 33 | indent_size = 2 34 | insert_final_newline = true 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tarsil] 2 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: "29 16 * * 6" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test and Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | schedule: 8 | - cron: "0 0 * * *" 9 | 10 | jobs: 11 | tests: 12 | name: Python ${{ matrix.python-version }} 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11"] 18 | 19 | services: 20 | db: 21 | image: postgres 22 | env: 23 | POSTGRES_HOST_AUTH_METHOD: trust 24 | POSTGRES_DB: dtu_test_project 25 | POSTGRES_PASSWORD: root 26 | POSTGRES_USER: postgres 27 | ports: 28 | - "5432:5432" 29 | options: >- 30 | --health-cmd pg_isready 31 | --health-interval 10s 32 | --health-timeout 5s 33 | --health-retries 5 34 | 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v3 38 | 39 | - name: Set Python version 40 | uses: actions/setup-python@v3 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | cache: "pip" 44 | cache-dependency-path: "requirements.txt" 45 | 46 | - name: Install Project dependencies 47 | run: | 48 | pip install -r requirements.txt 49 | python setup.py develop 50 | 51 | - name: Run tests 52 | run: | 53 | sudo apt install -y libmemcached-dev 54 | cd dtu_test_project 55 | python3 -m venv venv 56 | source ./venv/bin/activate 57 | pip install -r requirements.txt 58 | cd .. 59 | python setup.py develop 60 | cd dtu_test_project 61 | make test 62 | 63 | - if: always() 64 | name: Remove venv 65 | run: | 66 | rm -r dtu_test_project/venv 67 | 68 | - name: Install Python dependencies 69 | run: pip install wheel twine mkdocs-material 70 | 71 | - name: Build Python package 72 | run: python setup.py sdist bdist_wheel 73 | 74 | - if: ${{ github.ref == 'refs/heads/main' && 'main' || github.ref == 'refs/heads/develop' && 'develop' }} 75 | name: Upload to pypi. 76 | run: twine upload -u ${{ secrets.PYPI_USER }} -p ${{ secrets.PYPI_PASSWORD }} --skip-existing dist/*.whl 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | .DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | docs/_build/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | *.eggs 25 | .python-version 26 | 27 | # Pipfile 28 | Pipfile 29 | Pipfile.lock 30 | site/ 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coveragerc 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # IDEs 46 | .idea/ 47 | .vscode/ 48 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD003": { 4 | "style": "atx" 5 | }, 6 | "MD007": { "indent": 4 }, 7 | "MD009": { 8 | "br_spaces": 2, 9 | "list_item_empty_lines": true 10 | }, 11 | "MD010": { 12 | "code_blocks": false 13 | }, 14 | "MD012": true, 15 | "MD013": { 16 | "code_blocks": false, 17 | "line_length": 100, 18 | "tables": false 19 | }, 20 | "MD024": { 21 | "allow_different_nesting": true 22 | }, 23 | "MD026": { 24 | "punctuation": ".,;:!。,;:!" 25 | }, 26 | "MD027": true, 27 | "MD028": true, 28 | "MD030": { 29 | "ol_multi": 1, 30 | "ol_single": 1, 31 | "ul_multi": 1, 32 | "ul_single": 1 33 | }, 34 | "MD036": true, 35 | "MD037": true, 36 | "MD038": true, 37 | "MD039": true, 38 | "MD046": { 39 | "style": "fenced" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_stages: [commit, push] 2 | 3 | default_language_version: 4 | python: python3.8 5 | 6 | repos: 7 | - repo: git://github.com/pre-commit/pre-commit-hooks 8 | rev: v2.3.0 9 | hooks: 10 | - id: trailing-whitespace 11 | args: 12 | - --markdown-linebreak-ext=md 13 | - id: check-ast 14 | - id: check-case-conflict 15 | - id: check-docstring-first 16 | - id: check-json 17 | - id: check-merge-conflict 18 | - id: check-xml 19 | - id: check-yaml 20 | - id: detect-private-key 21 | - id: end-of-file-fixer 22 | - id: check-symlinks 23 | - id: no-commit-to-branch 24 | - id: debug-statements 25 | - id: pretty-format-json 26 | args: 27 | - --autofix 28 | - --no-sort-keys 29 | - id: requirements-txt-fixer 30 | - id: check-added-large-files 31 | args: 32 | - --maxkb=500 33 | - id: flake8 34 | args: 35 | - --max-line-length=120 36 | - --ignore=E731,W503,W504 37 | - repo: git://github.com/Lucas-C/pre-commit-hooks.git 38 | sha: v1.1.9 39 | hooks: 40 | - id: remove-crlf 41 | - id: remove-tabs 42 | args: ["--whitespaces-count", "2"] # defaults to: 4 43 | - repo: git://github.com/trbs/pre-commit-hooks-trbs.git 44 | sha: e233916fb2b4b9019b4a3cc0497994c7926fe36b 45 | hooks: 46 | - id: forbid-executables 47 | exclude: manage.py|setup.py 48 | #- repo: git://github.com/pre-commit/mirrors-csslint 49 | # sha: v1.0.5 50 | # hooks: 51 | # - id: csslint 52 | - repo: https://github.com/Lucas-C/pre-commit-hooks-safety 53 | sha: v1.1.0 54 | hooks: 55 | - id: python-safety-dependencies-check 56 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | # Path to sources 2 | sonar.sources=. 3 | sonar.exclusions=dtu_test_project/**/* 4 | 5 | # Source encoding 6 | sonar.sourceEncoding=UTF-8 -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Tiago Silva 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022-present Tiago Silva and contributors. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include AUTHORS 3 | include README.md 4 | include *.txt 5 | 6 | include VERSION 7 | recursive-include docs * 8 | recursive-include examples * 9 | recursive-include dtu_test_project * 10 | 11 | # exclude all bytecode 12 | global-exclude __pycache__ 13 | global-exclude *.py[co] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: help 4 | help: 5 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | .PHONY: all 8 | all: ## Initiate all tests 9 | init test 10 | 11 | .PHONY: requirements 12 | requirements: ## Initiate all tests 13 | pip install -r requirements.txt 14 | 15 | .PHONY: init 16 | init: ## Installs the develop tools and runs the coverage 17 | python setup.py develop 18 | pip install tox "coverage<5" 19 | 20 | .PHONY: test 21 | test: ## Runs the tests 22 | coverage erase 23 | tox --parallel--safe-build 24 | coverage html 25 | 26 | .PHONY: serve-docs 27 | serve-docs: ## Runs the local docs 28 | mkdocs serve 29 | 30 | .PHONY: build-docs 31 | build-docs: ## Runs the local docs 32 | mkdocs build 33 | 34 | ifndef VERBOSE 35 | .SILENT: 36 | endif 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Tenants URL 2 | 3 | ![Build and Publish](https://github.com/tarsil/django-tenants-url/actions/workflows/main.yml/badge.svg) 4 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=tarsil_django-tenants-url&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=tarsil_django-tenants-url) 5 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=tarsil_django-tenants-url&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=tarsil_django-tenants-url) 6 | [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=tarsil_django-tenants-url&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=tarsil_django-tenants-url) 7 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=tarsil_django-tenants-url&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=tarsil_django-tenants-url) 8 | 9 | **Official Documentation** - 10 | 11 | --- 12 | 13 | ## Table of Contents 14 | 15 | - [Django Tenants URL](#django-tenants-url) 16 | - [Table of Contents](#table-of-contents) 17 | - [About Django Tenants URL](#about-django-tenants-url) 18 | - [Dependencies](#dependencies) 19 | - [Motivation](#motivation) 20 | - [Installation](#installation) 21 | - [After installing django-tenants](#after-installing-django-tenants) 22 | - [Install django-tenants-url](#install-django-tenants-url) 23 | - [Django Tenants URL Settings](#django-tenants-url-settings) 24 | - [X_REQUEST_ID](#x_request_id) 25 | - [Example](#example) 26 | - [Documentation and Support](#documentation-and-support) 27 | - [License](#license) 28 | 29 | --- 30 | 31 | ## About Django Tenants URL 32 | 33 | Django Tenants URL is a wrapper on the top of the `django-tenants` package that serves a different 34 | yet common use case, the multi-tenant implementation via HEADER and not using `sub domains`. 35 | 36 | A special thanks to the team behind [Django Tenants](https://github.com/django-tenants/django-tenants). 37 | 38 | ## Dependencies 39 | 40 | The project contains [views](./views.md), [permissions](./permissions.md), 41 | [models](./models.md) and more addons that can be used across projects. 42 | 43 | `django-tenants-url` is built on the top of the following dependencies: 44 | 45 | 1. [Django](https://www.djangoproject.com/) 46 | 2. [Django Rest Framework](https://www.django-rest-framework.org/) 47 | 3. [Django Tenants](https://django-tenants.readthedocs.io/en/latest/) 48 | 49 | ## Motivation 50 | 51 | When implementing multi tenancy architecture there are many factors to consider and cover and 52 | those were greatly approached by [django-tenants](https://github.com/django-tenants/django-tenants) 53 | but so summarize, there are 3 common ways: 54 | 55 | 1. **Shared schemas** - The data of all users are shared within the same schema and filtered by 56 | common IDs or whatever that is unique to the platform. This is not so great for GDPR. 57 | 2. **Shared database, different Schemas** - The user's data is split by different schemas but live 58 | on the same database. 59 | 3. **Different databases** - The user's data or any data live on different databases. 60 | 61 | As mentioned before, `django-tenants-url` is a wrapper on the top of `django-tenants` 62 | and therefore we will be approaching the second. 63 | 64 | Many companies have limited resources (money, people...) and limited choices from those 65 | constraints. When implementing multi tenancy, the default would be to use subdomains 66 | in order to access the desired schema. E.g.: 67 | 68 | ```shell 69 | www.mycompany.saascompany.com 70 | ``` 71 | 72 | `My Company` is the tenant of the `saascompany.com` that is publicaly available to the users. 73 | When the `mycompany` is sent to the backend, the middleware splits the subdomain and 74 | the TLD (top-level domain) and maps the tenant with the schema associated. 75 | 76 | For this work, one of the ways is to change your `apache`, `nginx` or any other configurations 77 | that accepts and forwards calls to the `*.sasscompany.com` and performs the above action. 78 | 79 | If the frontend and backend are split, extra configurations need also to be made on that 80 | front and all of this can be a pain. 81 | 82 | **What does django-tenants-url solve?** 83 | 84 | The principle of mapping users to the schemas remains the same but the way of doing it 85 | is what diverges from the rest. What if we were able to only use `www.sasscompany.com`, 86 | login as usual and automatically the platform knows exactly to which schema the user 87 | needs to be mapped and forward? 88 | 89 | **This is what django-tenants-url solves. A single url that does the multi tenancy 90 | without breaking the principle and architecture and simply using one single url** 91 | 92 | ## Installation 93 | 94 | Prior to install the `django-tenants-url`, `django-tenants` needs to be installed 95 | as well. Please follow the installation steps from 96 | [Django Tenants](https://www.github.com/django-tenants/django-tenants) 97 | 98 | ### After installing django-tenants 99 | 100 | #### Install django-tenants-url 101 | 102 | ```shell 103 | pip install django-tenants-url 104 | ``` 105 | 106 | 1. The `TENANT_MODEL` and `TENANT_DOMAIN_MODEL` from `django-tenants` 107 | need to be also in the `settings.py`. 108 | 2. Add `django-tenants-url` to `INSTALLED_APPS`. 109 | 110 | ```python 111 | 112 | INSTALLED_APPS = [ 113 | ... 114 | 'django_tenants', 115 | 'django_tenants_url', 116 | ... 117 | ] 118 | 119 | ``` 120 | 121 | 3. `django-tenants-url` offers a special wrapper over the `mixins` of `django-tenants` 122 | with the same names so you don't need to worry about breaking changes and the 123 | additional unique `TenantUserMixin` that maps the users with a tenant. 124 | 125 | 4. Create the models. 126 | 127 | ```python 128 | # myapp.models.py 129 | 130 | from django.db import models 131 | from django_tenants_url.models import TenantMixin, DomainMixin, TenantUserMixin 132 | 133 | 134 | class Client(TenantMixin): 135 | """ 136 | This table provides the `tenant_uuid` needed 137 | to be used in the `X_REQUEST_HEADER` and consumed 138 | by the RequestUUIDTenantMiddleware. 139 | """ 140 | pass 141 | 142 | 143 | class Domain(DomainMixin): 144 | pass 145 | 146 | 147 | class TenantUser(TenantUserMixin): 148 | pass 149 | 150 | ``` 151 | 152 | 5. Add the `DTU_TENANT_USER_MODEL` to `settings.py`. 153 | 154 | ```python 155 | # settings.py 156 | 157 | ... 158 | 159 | DTU_TENANT_USER_MODEL = 'myapp.TenantUser' 160 | 161 | ... 162 | 163 | ``` 164 | 165 | 6. Update the `MIDDLEWARE` to have the new `RequestUUIDTenantMiddleware`. 166 | Preferentially at the very top. 167 | 168 | ```python 169 | # settings.py 170 | 171 | ... 172 | 173 | MIDDLEWARE = [ 174 | 'django_tenants_url.middleware.RequestUUIDTenantMiddleware', 175 | 'django.middleware.security.SecurityMiddleware', 176 | ... 177 | ] 178 | 179 | ... 180 | ``` 181 | 182 | 7. Generate the migrations. 183 | 184 | ```shell 185 | python manage.py makemigrations 186 | ``` 187 | 188 | 8. Run the migrations as if it was `django-tenants` and not the classic `migrate`. 189 | 190 | ```shell 191 | python manage.py migrate_schemas 192 | ``` 193 | 194 | 9. The `UUID` needed for the `RequestUUIDTenantMiddleware` can be found in your 195 | table inherited from the `TenantMixin`. 196 | 197 | **None: Do not run `python manage.py migrate` or else it will sync everything into the public.** 198 | 199 | And that is it. The `RequestUUIDTenantMiddleware` should be able to map 200 | the `TenantUser` created with a tenant and route the queries to the associated schema. 201 | 202 | Checkout the [documentation](https://django-tenants-url.tarsild.io/) 203 | and understand how to integrate with your views and taking advantage 204 | of the utils for your `TenantUser` (or your implementation), 205 | 206 | ### Django Tenants URL Settings 207 | 208 | ```python 209 | # default settings 210 | 211 | DTU_TENANT_NAME = "Public" 212 | DTU_TENANT_SCHEMA = "public" 213 | DTU_DOMAIN_NAME = "localhost" 214 | DTU_PAID_UNTIL = "2100-12-31" 215 | DTU_ON_TRIAL = False 216 | DTU_HEADER_NAME = "HTTP_X_REQUEST_ID" 217 | DTU_AUTO_CREATE_SCHEMA = True 218 | DTU_AUTO_DROP_SCHEMA = False 219 | DTU_TENANT_USER_MODEL = None 220 | 221 | ``` 222 | 223 | ### X_REQUEST_ID 224 | 225 | By default `django-tenants-url` has the header name `HTTP_X_REQUEST_ID` that will be lookup 226 | from the middleware when sent via HTTP. 227 | This name can be overriten by the special setting `DTU_HEADER_NAME`. 228 | 229 | ## Example 230 | 231 | A simple example can be found [here](./docs/example.md). 232 | 233 | Another Django Like app implementing Django Tenants Url can be found [here](./dtu_test_project/) 234 | 235 | ## Documentation and Support 236 | 237 | Full documentation for the project is available at 238 | 239 | ## License 240 | 241 | Copyright (c) 2022-present Tiago Silva and contributors under the [MIT license](https://opensource.org/licenses/MIT). 242 | -------------------------------------------------------------------------------- /django_tenants_url/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = "Django Tenants URL" 2 | __version__ = "1.0.1" 3 | __author__ = "Tiago Silva" 4 | __license__ = "MIT" 5 | 6 | # Version synonym 7 | VERSION = __version__ 8 | -------------------------------------------------------------------------------- /django_tenants_url/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | from . import settings as tenant_settings 6 | 7 | 8 | class DjangoTenantsUrlConfig(AppConfig): 9 | name = "django_tenants_url" 10 | label = "django_tenants_url" 11 | verbose_name = "Django Tenants URL" 12 | 13 | def ready(self): 14 | """ 15 | Make sure the default and mandatory values are checked before 16 | the app is up and running. 17 | """ 18 | 19 | DEFAULT_SETTING_FIELDS = [ 20 | "DTU_TENANT_NAME", 21 | "DTU_TENANT_SCHEMA", 22 | "DTU_DOMAIN_NAME", 23 | "DTU_PAID_UNTIL", 24 | "DTU_ON_TRIAL", 25 | "DTU_HEADER_NAME", 26 | "DTU_AUTO_CREATE_SCHEMA", 27 | "DTU_AUTO_DROP_SCHEMA", 28 | "DTU_TENANT_USER_MODEL", 29 | ] 30 | 31 | # Test for configuration recommendations. These are best practices, 32 | # they avoid hard to find bugs and unexpected behaviour. 33 | if not hasattr(settings, "DTU_TENANT_USER_MODEL"): 34 | raise ImproperlyConfigured("DTU_TENANT_USER_MODEL is not set.") 35 | 36 | # Sets defaults if not declared in the settings 37 | for value in DEFAULT_SETTING_FIELDS: 38 | if not hasattr(settings, value): 39 | setattr(settings, value, getattr(tenant_settings, value)) 40 | -------------------------------------------------------------------------------- /django_tenants_url/handlers.py: -------------------------------------------------------------------------------- 1 | from django_tenants.utils import get_tenant_domain_model, get_tenant_model 2 | 3 | from .utils import get_tenant_user_model 4 | 5 | 6 | def handle_domain(domain_url, tenant, is_primary): 7 | """ 8 | Creates/Updates a domain and set the primary. 9 | """ 10 | domain, _ = get_tenant_domain_model().objects.update_or_create( 11 | domain=domain_url, tenant=tenant, defaults={"is_primary": is_primary} 12 | ) 13 | return domain 14 | 15 | 16 | def handle_tenant( 17 | domain_url, tenant_name, schema_name, paid_until=None, on_trial=False 18 | ): 19 | """ 20 | Creates/Updates a tenant. 21 | """ 22 | tenant, _ = get_tenant_model().objects.update_or_create( 23 | domain_url=domain_url, 24 | tenant_name=tenant_name, 25 | schema_name=schema_name, 26 | defaults={"paid_until": paid_until, "on_trial": on_trial}, 27 | ) 28 | return tenant 29 | 30 | 31 | def handle_tenant_user(tenant, user, is_active): 32 | """ 33 | Creates/Updates a tenant. 34 | """ 35 | tenant_user, _ = get_tenant_user_model().objects.update_or_create( 36 | tenant=tenant, 37 | user=user, 38 | defaults={ 39 | "is_active": is_active, 40 | }, 41 | ) 42 | return tenant_user 43 | -------------------------------------------------------------------------------- /django_tenants_url/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | All things middleware 3 | """ 4 | from django.conf import settings 5 | from django.core.exceptions import ObjectDoesNotExist 6 | from django.db import connection 7 | from django_tenants.middleware import TenantMainMiddleware 8 | from django_tenants.utils import ( 9 | get_public_schema_name, 10 | get_tenant_domain_model, 11 | get_tenant_model, 12 | ) 13 | 14 | from .handlers import handle_domain, handle_tenant 15 | 16 | 17 | class RequestUUIDTenantMiddleware(TenantMainMiddleware): 18 | """ 19 | Handler of the UUID sent from the request of a user. 20 | The typical multi-tenant approach would be to split the subdomain 21 | from the top-level domain but that would also imply handling with those 22 | configurations via external mapping. 23 | 24 | This middleware does the same job but simply using a UUID sent from the 25 | header of a request and maps the tenant internally as it was with the subdomain. 26 | 27 | Using `django-tenants` also means using their configurations without 28 | breaking any existing functionality such as domain creation per tenant and client models. 29 | """ 30 | 31 | def handle_public_domain(self, domain_url, tenant, is_primary): 32 | """ 33 | Creates an initial public domain in the database. 34 | """ 35 | return handle_domain(domain_url, tenant, is_primary) 36 | 37 | def handle_public_tenant( 38 | self, domain_url, tenant_name, schema_name, paid_until, on_trial 39 | ): 40 | """ 41 | Creates an initial public tenant. 42 | """ 43 | return handle_tenant(domain_url, tenant_name, schema_name, paid_until, on_trial) 44 | 45 | def process_request(self, request): 46 | """ 47 | The connection needs first to be at the public tenant as this is where 48 | the tenant metadata is stored. 49 | """ 50 | connection.set_schema_to_public() 51 | hostname = self.hostname_from_request(request) 52 | 53 | domain_model = get_tenant_domain_model() 54 | try: 55 | tenant = self.get_tenant(domain_model, hostname, request) 56 | except domain_model.DoesNotExist: 57 | self.no_tenant_found(request, hostname) 58 | return 59 | 60 | tenant.domain_url = hostname 61 | request.tenant = tenant 62 | connection.set_tenant(request.tenant) 63 | self.setup_url_routing(request) 64 | 65 | def get_tenant(self, model, hostname, request): 66 | """ 67 | Gets the tenant (public or assigned) from the database. 68 | If no tenant is found, defaults to public. 69 | 70 | 1. Get the domain 71 | 2. Create the schema. 72 | 1. If domain doesn't exist, then creates the schema with the newly assigned domain. 73 | 3. Get the request header. 74 | 4. Return the tenant. 75 | """ 76 | try: 77 | domain = model.objects.get(tenant__schema_name=get_public_schema_name()) 78 | schema = domain.tenant 79 | except ObjectDoesNotExist: 80 | schema = self.handle_public_tenant( 81 | domain_url=hostname, 82 | tenant_name=settings.DTU_TENANT_NAME, 83 | schema_name=settings.DTU_TENANT_SCHEMA, 84 | paid_until=settings.DTU_PAID_UNTIL, 85 | on_trial=settings.DTU_ON_TRIAL, 86 | ) 87 | self.handle_public_domain(schema.domain_url, schema, True) 88 | schema.save() 89 | 90 | x_request_id = request.META.get(settings.DTU_HEADER_NAME, schema.tenant_uuid) 91 | 92 | try: 93 | tenant = get_tenant_model().objects.get(tenant_uuid=x_request_id) 94 | except get_tenant_domain_model().DoesNotExist: 95 | tenant = schema 96 | 97 | return tenant 98 | -------------------------------------------------------------------------------- /django_tenants_url/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | 4 | from django.conf import settings 5 | from django.db import models 6 | from django_tenants.models import DomainMixin as TenantDomainMixin 7 | from django_tenants.models import TenantMixin as TenantsMixin 8 | 9 | from django_tenants_url.utils import get_tenant_user_model 10 | 11 | 12 | class TenantMixin(TenantsMixin): 13 | """ 14 | Model used to map clients (tenants) with the application. 15 | """ 16 | 17 | domain_url = models.URLField(blank=True, null=True, default=os.getenv("DOMAIN")) 18 | tenant_name = models.CharField(max_length=100, unique=True, null=False, blank=False) 19 | tenant_uuid = models.UUIDField(default=uuid.uuid4, null=False, blank=False) 20 | paid_until = models.DateField(null=True, blank=True) 21 | on_trial = models.BooleanField(null=True, blank=True) 22 | created_on = models.DateField(auto_now_add=True) 23 | 24 | # Default True, scgema will be automatically created and synched when it is saved. 25 | auto_create_schema = getattr(settings, "DTU_AUTO_CREATE_SCHEMA", True) 26 | auto_drop_schema = getattr(settings, "DTU_AUTO_DROP_SCHEMA", False) 27 | 28 | REQUIRED_FIELDS = ("tenant_name", "schema_name") 29 | 30 | class Meta: 31 | abstract = True 32 | 33 | def delete(self, force_drop=False, *args, **kwargs): 34 | """ 35 | Drops the tenant schema and prevents dropping the public schema. 36 | """ 37 | if self.schema_name == settings.DTU_TENANT_SCHEMA: 38 | raise ValueError("Cannot drop public schema.") 39 | 40 | self._drop_schema(force_drop) 41 | super().delete(*args, **kwargs) 42 | 43 | def __str__(self): 44 | return f"{self.tenant_name} - {self.created_on}" 45 | 46 | 47 | class DomainMixin(TenantDomainMixin): 48 | """ 49 | Model used to map a domain (or many) with a tenant. 50 | """ 51 | 52 | class Meta: 53 | abstract = True 54 | 55 | def delete(self, *args, **kwargs): 56 | """ 57 | Deletes a domain and prevents deleting the public domain. 58 | """ 59 | if ( 60 | self.tenant.schema_name == settings.DTU_TENANT_SCHEMA 61 | and self.domain == settings.DTU_DOMAIN_NAME 62 | ): 63 | raise ValueError("Cannot drop public domain.") 64 | super().delete(*args, **kwargs) 65 | 66 | 67 | class TenantUserMixin(models.Model): 68 | """ 69 | Mapping between user and a client (tenant). 70 | """ 71 | 72 | user = models.ForeignKey( 73 | settings.AUTH_USER_MODEL, 74 | null=False, 75 | blank=False, 76 | on_delete=models.CASCADE, 77 | related_name="tenant_users", 78 | ) 79 | tenant = models.ForeignKey( 80 | settings.TENANT_MODEL, 81 | null=False, 82 | blank=False, 83 | on_delete=models.CASCADE, 84 | related_name="tenant_users", 85 | ) 86 | is_active = models.BooleanField(default=False) 87 | created_on = models.DateField(auto_now_add=True) 88 | 89 | class Meta: 90 | abstract = True 91 | 92 | def __str__(self): 93 | return f"{self.user.pk}, Tenant: {self.tenant}" 94 | 95 | def save(self, *args, **kwargs): 96 | super().save(*args, **kwargs) 97 | if self.is_active: 98 | qs = ( 99 | get_tenant_user_model() 100 | .objects.filter(is_active=True, user=self.user) 101 | .exclude(pk=self.pk) 102 | ) 103 | qs.update(is_active=False) 104 | -------------------------------------------------------------------------------- /django_tenants_url/permissions.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.exceptions import NotFound 3 | from rest_framework.permissions import BasePermission 4 | 5 | from .utils import get_tenant_user_model 6 | 7 | 8 | class BaseTenantPermission(BasePermission): 9 | """ 10 | Simple base permission to handle with tenants with a user. 11 | 12 | If header is not passed, then means public. 13 | """ 14 | 15 | HEADER_NAME = settings.DTU_HEADER_NAME 16 | 17 | def has_tenant(self, request): 18 | """ 19 | If not header, then tre for public. 20 | """ 21 | header = request.META.get(self.HEADER_NAME, None) 22 | 23 | if not header: 24 | return True 25 | 26 | return bool( 27 | get_tenant_user_model() 28 | .objects.filter(user=request.user, tenant__tenant_uuid=header) 29 | .exists() 30 | ) 31 | 32 | def get_tenant_user(self, request): 33 | header = request.META.get(self.HEADER_NAME, None) 34 | 35 | if not header: 36 | return True 37 | 38 | try: 39 | return get_tenant_user_model().objects.get( 40 | user=request.user, tenant__tenant_uuid=header 41 | ) 42 | except get_tenant_user_model().DoesNotExist: 43 | raise NotFound() 44 | 45 | 46 | class IsTenantAllowedOrPublic(BaseTenantPermission): 47 | """ 48 | Permission for tenant in a view. 49 | Verify if there is a tenant uuid passed in the headers. 50 | 51 | True if exists and/or is public. False otherwise. 52 | """ 53 | 54 | def has_permission(self, request, view): 55 | return self.has_tenant(request) 56 | -------------------------------------------------------------------------------- /django_tenants_url/serializers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | import bleach 5 | from django.conf import settings 6 | from django.contrib.auth import get_user_model 7 | from django.db import IntegrityError 8 | from django.db.models import Q 9 | from django_tenants.utils import get_tenant_model 10 | from rest_framework import serializers 11 | 12 | from .handlers import handle_domain, handle_tenant 13 | from .utils import get_tenant_user_model 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class TenantSerializer(serializers.Serializer): 19 | """ 20 | Basic tenant information. 21 | """ 22 | 23 | tenant_name = serializers.UUIDField() 24 | tenant_uuid = serializers.UUIDField() 25 | 26 | 27 | class TenantListSerializer(serializers.ModelSerializer): 28 | class Meta: 29 | model = get_tenant_model() 30 | fields = ["id", "tenant_name", "tenant_uuid"] 31 | 32 | 33 | class CreateTenantSerializer(serializers.Serializer): 34 | """ 35 | Default serializer for the creation of a tenant. 36 | """ 37 | 38 | tenant_name = serializers.CharField(required=True) 39 | schema_name = serializers.CharField(required=True) 40 | domain_url = serializers.CharField(required=True) 41 | on_trial = serializers.BooleanField(default=False) 42 | paid_until = serializers.DateField(required=False) 43 | is_primary = serializers.BooleanField(default=True) 44 | 45 | def _sanitize_field(self, field, value): 46 | """ 47 | Sanitizes and cleans the data. 48 | """ 49 | try: 50 | return bleach.clean(value) 51 | except (TypeError, ValueError): 52 | raise serializers.ValidationError(f"Invalid value for {field}.") 53 | 54 | def validate_tenant_name(self, tenant_name): 55 | return self._sanitize_field("tenant_name", tenant_name) 56 | 57 | def validate_schema_name(self, schema_name): 58 | schema_name = self._sanitize_field("schema_name", schema_name) 59 | schema_name = schema_name.replace("-", "_") 60 | return schema_name.lower() 61 | 62 | def validate_domain_url(self, domain_url): 63 | domain_url = self._sanitize_field("domain_url", domain_url) 64 | return domain_url.lower() 65 | 66 | def validate(self, attrs): 67 | """ 68 | Validates if the tenant is a trial or not and raises error if true and date is missing. 69 | 70 | Make sure that there no duplicate schemas by querying the db and ignoring sensitive cases 71 | and returning a bool. 72 | """ 73 | on_trial = attrs["on_trial"] 74 | paid_until = attrs.get("paid_until", None) 75 | 76 | if not on_trial: 77 | if not paid_until: 78 | raise serializers.ValidationError( 79 | {"paid_until": "paid_until date is required when on trial."} 80 | ) 81 | 82 | attrs["paid_until"] = settings.DTU_PAID_UNTIL 83 | return attrs 84 | 85 | if not paid_until: 86 | raise serializers.ValidationError( 87 | {"paid_until": "paid_until date is required when on trial."} 88 | ) 89 | else: 90 | paid_until = datetime.strptime(paid_until, "%Y-%m-%d") 91 | now = datetime.utcnow() 92 | 93 | if paid_until < now: 94 | raise serializers.ValidationError( 95 | {"paid_until": "paid_until cannot be before today's date."} 96 | ) 97 | 98 | exists = get_tenant_model().objects.filter( 99 | Q(schema_name__iexact=attrs["schema_name"]) 100 | | Q(tenant_name__iexact=attrs["tenant_name"]) 101 | | Q(tenant_name__iexact=attrs["domain_url"]) 102 | ) 103 | 104 | if exists: 105 | raise serializers.ValidationError( 106 | "There is already a tenant with the same name and/or schema." 107 | ) 108 | 109 | return attrs 110 | 111 | def create(self, validated_data): 112 | """ 113 | Creates a tenant and a domain for that same tenant. 114 | """ 115 | tenant = handle_tenant( 116 | domain_url=validated_data["domain_url"].lower(), 117 | tenant_name=validated_data["tenant_name"], 118 | schema_name=validated_data["schema_name"], 119 | on_trial=validated_data["on_trial"], 120 | paid_until=validated_data["paid_until"], 121 | ) 122 | 123 | handle_domain(tenant.domain_url, tenant, validated_data["is_primary"]) 124 | 125 | 126 | class TenantUserSerializer(serializers.Serializer): 127 | is_active = serializers.BooleanField(required=True) 128 | user_id = serializers.IntegerField(required=True) 129 | tenant_id = serializers.IntegerField(required=True) 130 | 131 | def validate_user_id(self, user_id): 132 | exists = get_user_model().objects.filter(pk=user_id).exists() 133 | if not exists: 134 | raise serializers.ValidationError( 135 | {"user_id": f"User {user_id} does not exist."} 136 | ) 137 | return user_id 138 | 139 | def validate_tenant_id(self, tenant_id): 140 | exists = get_tenant_model().objects.filter(pk=tenant_id).exists() 141 | if not exists: 142 | raise serializers.ValidationError( 143 | {"tenant_id": f"Tenant {tenant_id} does not exist."} 144 | ) 145 | return tenant_id 146 | 147 | def create(self, validated_data): 148 | user_id = validated_data["user_id"] 149 | tenant_id = validated_data["tenant_id"] 150 | 151 | exists = ( 152 | get_tenant_user_model() 153 | .objects.filter(user_id=user_id, tenant_id=tenant_id) 154 | .exists() 155 | ) 156 | if exists: 157 | raise serializers.ValidationError( 158 | "There is already a record for this user and tenant." 159 | ) 160 | 161 | try: 162 | instance = get_tenant_user_model().objects.create( 163 | user_id=user_id, 164 | tenant_id=tenant_id, 165 | is_active=validated_data["is_active"], 166 | ) 167 | except IntegrityError as e: 168 | logger.exception(e) 169 | raise e 170 | 171 | tenants = get_tenant_user_model().objects.all() 172 | if tenants.count() == 1: 173 | instance.is_active = True 174 | instance.save() 175 | 176 | return instance 177 | -------------------------------------------------------------------------------- /django_tenants_url/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Settings used for Django Tenants URL 3 | """ 4 | 5 | DTU_TENANT_NAME = "Public" 6 | DTU_TENANT_SCHEMA = "public" 7 | DTU_DOMAIN_NAME = "localhost" 8 | DTU_PAID_UNTIL = "2100-12-31" 9 | DTU_ON_TRIAL = False 10 | DTU_HEADER_NAME = "HTTP_X_REQUEST_ID" 11 | DTU_AUTO_CREATE_SCHEMA = True 12 | DTU_AUTO_DROP_SCHEMA = False 13 | DTU_TENANT_USER_MODEL = None 14 | -------------------------------------------------------------------------------- /django_tenants_url/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import ( 4 | CreateTenantAPIView, 5 | DestroyTenantAPIView, 6 | DestroyTenantUserAPIView, 7 | SetActiveTenantUserAPIView, 8 | TenantAPIView, 9 | TenantListAPIView, 10 | TenantUserAPIView, 11 | ) 12 | 13 | urlpatterns = [ 14 | path("info", TenantAPIView.as_view(), name="tenant-info"), 15 | path("tenant", CreateTenantAPIView.as_view(), name="create-tenant"), 16 | path( 17 | "tenant/delete/", DestroyTenantAPIView.as_view(), name="delete-tenant" 18 | ), 19 | path("tenants", TenantListAPIView.as_view(), name="tenant-list"), 20 | path("tenant/user", TenantUserAPIView.as_view(), name="create-tenant-user"), 21 | path( 22 | "tenant/user//", 23 | DestroyTenantUserAPIView.as_view(), 24 | name="delete-tenant-user", 25 | ), 26 | path( 27 | "tenant/user//set-active", 28 | SetActiveTenantUserAPIView.as_view(), 29 | name="set-active-tenant", 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /django_tenants_url/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django_tenants.utils import get_tenant_model 3 | 4 | try: 5 | from django.apps import apps 6 | 7 | get_model = apps.get_model 8 | except ImportError: 9 | from django.db.models.loading import get_model 10 | 11 | 12 | def get_tenant_user_model(): 13 | return get_model(settings.DTU_TENANT_USER_MODEL) 14 | 15 | 16 | def get_active_user_tenant(user): 17 | """ 18 | Obtains the active user tenant. 19 | """ 20 | from .serializers import TenantSerializer 21 | 22 | try: 23 | tenant = get_tenant_user_model().objects.get(user=user, is_active=True) 24 | tenant = tenant.tenant 25 | except get_tenant_user_model().DoesNotExist: 26 | return 27 | 28 | serializer = TenantSerializer(tenant) 29 | return serializer.data 30 | 31 | 32 | def get_user_tenants(user): 33 | """ 34 | Lists the tenants associated with a user. 35 | """ 36 | from .serializers import TenantListSerializer 37 | 38 | tenants = get_tenant_user_model().objects.filter(user=user) 39 | if not tenants: 40 | try: 41 | tenants = [ 42 | get_tenant_model().objects.get(tenant_name=settings.DTU_TENANT_NAME) 43 | ] 44 | except get_tenant_model().DoesNotExist: 45 | return [] 46 | else: 47 | tenants = [tenant.tenant for tenant in tenants] 48 | 49 | serializer = TenantListSerializer(tenants, many=True) 50 | return serializer.data 51 | 52 | 53 | def get_tenants(): 54 | """ 55 | Returns a list of all tenants. 56 | """ 57 | tenants = get_tenant_model().objects.all() 58 | serializer = TenantListSerializer(tenants, many=True) 59 | return serializer.data 60 | -------------------------------------------------------------------------------- /django_tenants_url/views.py: -------------------------------------------------------------------------------- 1 | from django_tenants.utils import get_tenant_domain_model, get_tenant_model 2 | from rest_framework import status 3 | from rest_framework.exceptions import ParseError 4 | from rest_framework.generics import ListAPIView 5 | from rest_framework.response import Response 6 | from rest_framework.views import APIView 7 | 8 | from .serializers import ( 9 | CreateTenantSerializer, 10 | TenantListSerializer, 11 | TenantSerializer, 12 | TenantUserSerializer, 13 | ) 14 | from .utils import get_tenant_user_model 15 | 16 | 17 | class TenantListAPIView(ListAPIView): 18 | """ 19 | Lists all the tenants in the system. 20 | """ 21 | 22 | serializer_class = TenantListSerializer 23 | 24 | def get_queryset(self): 25 | return get_tenant_model().objects.all() 26 | 27 | 28 | class TenantAPIView(APIView): 29 | """ 30 | Get the information of a given tenant. 31 | Returns the name and the UUID. 32 | """ 33 | 34 | serializer_class = TenantSerializer 35 | 36 | def post(self, request, *args, **kwargs): 37 | data = request.data or {} 38 | tenant_name = data.get("tenant_name") 39 | 40 | if tenant_name is None: 41 | raise ParseError(detail="tenant_name missing.") 42 | 43 | try: 44 | tenant = get_tenant_model().objects.get(tenant_name__iexact=tenant_name) 45 | except get_tenant_model().DoesNotExist: 46 | raise ParseError(detail=f"{tenant_name} does not exist.") 47 | 48 | serializer = self.serializer_class(tenant, many=False) 49 | return Response(serializer.data, status=status.HTTP_200_OK) 50 | 51 | 52 | class CreateTenantAPIView(APIView): 53 | """ 54 | Creates a tenant and corresponding schema. 55 | """ 56 | 57 | serializer_class = CreateTenantSerializer 58 | 59 | def post(self, request, *args, **kwargs): 60 | serializer = self.serializer_class(data=request.data) 61 | serializer.is_valid(raise_exception=True) 62 | serializer.save() 63 | return Response(status=status.HTTP_201_CREATED) 64 | 65 | 66 | class DestroyTenantAPIView(APIView): 67 | def get_tenant_domains(self, tenant): 68 | """ 69 | Gets the domains of a tenant. 70 | """ 71 | return get_tenant_domain_model().objects.filter(tenant=tenant) 72 | 73 | def get_tenant(self, pk): 74 | try: 75 | return get_tenant_model().objects.get(pk=pk) 76 | except get_tenant_model().DoesNotExist: 77 | raise ParseError(detail=f"Tenant {pk} does not exist.") 78 | 79 | def delete(self, request, pk): 80 | """ 81 | Deletes a tenant and corresponding domains. 82 | """ 83 | tenant = self.get_tenant(pk) 84 | domains = self.get_tenant_domains(tenant) 85 | 86 | try: 87 | tenant.delete() 88 | domains.delete() 89 | except ValueError: 90 | raise ParseError(detail=str(e)) 91 | 92 | return Response(status=status.HTTP_204_NO_CONTENT) 93 | 94 | 95 | class TenantUserAPIView(APIView): 96 | """ 97 | Associate a user with a Tenant. 98 | """ 99 | 100 | serializer_class = TenantUserSerializer 101 | 102 | def post(self, request, *args, **kwargs): 103 | serializer = self.serializer_class(data=request.data) 104 | serializer.is_valid(raise_exception=True) 105 | serializer.save() 106 | 107 | return Response(status=status.HTTP_201_CREATED) 108 | 109 | 110 | class DestroyTenantUserAPIView(APIView): 111 | """ 112 | Removes the association of a user and a tenant. 113 | """ 114 | 115 | def get_tenant_user(self, user_id, tenant_id): 116 | try: 117 | return get_tenant_user_model().objects.get(pk=tenant_id, user_id=user_id) 118 | except get_tenant_user_model().DoesNotExist: 119 | raise ParseError(detail=f"Tenant user does not exist.") 120 | 121 | def delete(self, request, user_id, tenant_id): 122 | instance = self.get_tenant_user(user_id, tenant_id) 123 | instance.delete() 124 | 125 | return Response(status=status.HTTP_204_NO_CONTENT) 126 | 127 | 128 | class SetActiveTenantUserAPIView(APIView): 129 | """ 130 | Sets the current active tenant of a logged in user. 131 | """ 132 | 133 | def get_tenant_user(self, tenant_id): 134 | try: 135 | return get_tenant_user_model().objects.get( 136 | user=self.request.user, tenant_id=tenant_id 137 | ) 138 | except get_tenant_user_model().DoesNotExist: 139 | raise ParseError(detail=f"Tenant {tenant_id} for current user not found.") 140 | 141 | def put(self, request, tenant_id, **kwargs): 142 | instance = self.get_tenant_user(tenant_id) 143 | instance.is_active = True 144 | instance.save() 145 | 146 | return Response(status=status.HTTP_200_OK) 147 | -------------------------------------------------------------------------------- /docs/example.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | --- 4 | 5 | Here follows an example in how to use `django-tenants-url`. 6 | 7 | ## Table of Contents 8 | 9 | - [Example](#example) 10 | - [Table of Contents](#table-of-contents) 11 | - [Initial Notes](#initial-notes) 12 | - [Install package](#install-package) 13 | - [Settings](#settings) 14 | - [Create Tenant django app](#create-tenant-django-app) 15 | - [Create and run migrations](#create-and-run-migrations) 16 | - [Update settings](#update-settings) 17 | - [Start the server](#start-the-server) 18 | - [Get a Tenant UUID](#get-a-tenant-uuid) 19 | - [Use the UUID within the headers](#use-the-uuid-within-the-headers) 20 | 21 | --- 22 | 23 | ## Initial Notes 24 | 25 | This is a very basic example how to see the UUID of a tenant and use it in the headers. 26 | For more robust and secure approach we also provide [views](./views.md), 27 | [serializers](./serializers.md), [utils](./utils.md) and some [permissions](./permissions.md) 28 | and [handlers](./handlers.md) that can be used accordingly. 29 | 30 | ## Install package 31 | 32 | ```shell 33 | pip install django-tenants-url 34 | ``` 35 | 36 | ## Settings 37 | 38 | After installing `django-tenants-url`, add it into the settings. 39 | 40 | ```python 41 | 42 | INSTALLED_APPS = [ 43 | ... 44 | 'django_tenants', 45 | 'django_tenants_url', 46 | ... 47 | ] 48 | ``` 49 | 50 | ## Create Tenant django app 51 | 52 | Let's create a tenant custom app and add it into the settings. 53 | 54 | 1. Create the app 55 | 56 | ```shell 57 | django-admin startapp tenant 58 | ``` 59 | 60 | 2. Add the app to the `INSTALLED_APPS`. 61 | 62 | 3. ```python 63 | INSTALLED_APPS = [ 64 | ... 65 | 'django_tenants', 66 | 'django_tenants_url', 67 | 'tenant', 68 | ... 69 | ] 70 | ``` 71 | 72 | More settings will be needed but we will back later on to update the settings. 73 | 74 | ## Create the models 75 | 76 | 1. Inside the newly created `tenant` in the `models.py`. 77 | 78 | ```python 79 | from django_tenants_url.models import TenantMixin, DomainMixin, TenantUserMixin 80 | 81 | 82 | class Client(TenantMixin): 83 | pass 84 | 85 | 86 | class Domain(DomainMixin): 87 | pass 88 | 89 | 90 | class TenantUser(TenantUserMixin): 91 | pass 92 | 93 | ``` 94 | 95 | ## Create and run migrations 96 | 97 | 1. Create migrations. 98 | 99 | ```shell 100 | python manage.py makemigrations 101 | ``` 102 | 103 | 2. Run migrations. 104 | 105 | ```shell 106 | python manage.py migrate_schemas 107 | ``` 108 | 109 | ## Update settings 110 | 111 | With the models created we can now update the `settings.py`. 112 | 113 | 1. Add extra needed settings. 114 | 115 | ```python 116 | 117 | INSTALLED_APPS = [...] 118 | 119 | TENANT_MODEL = 'tenant.Client' # needed from django-tenants 120 | 121 | TENANT_DOMAIN_MODEL = 'tenant.Domain' # needed from django-tenants 122 | 123 | # DTU_TENANT_USER_MODEL 124 | DTU_TENANT_USER_MODEL = 'tenant.TenantUser' # unique to django-tenants-url 125 | 126 | ``` 127 | 128 | 2. Update the `MIDDLEWARE`. 129 | 130 | ```python 131 | MIDDLEWARE = [ 132 | 'django_tenants_url.middleware.RequestUUIDTenantMiddleware', 133 | 'django.middleware.security.SecurityMiddleware', 134 | ... 135 | ] 136 | ``` 137 | 138 | ## Start the server 139 | 140 | Once the first request hits the server, it should create the public tenant and public domain. 141 | 142 | ## Get a Tenant UUID 143 | 144 | `django-tenants-url` provides out-of-the-box [views](./views.md) to help you with all of the process as well 145 | as some functions that can be used to get some of the information like the UUID needed 146 | to be used in the header of a request and map to user schema. 147 | 148 | ```python 149 | from django_tenants_url.utils import get_tenants 150 | 151 | tenants = get_tenants() 152 | print(tenants) 153 | 154 | [ 155 | { 156 | "id": 1, 157 | "tenant_name": "Public", 158 | "tenant_uuid": "a66b19e4-3985-42a1-87e1-338707c4a203" 159 | } 160 | ] 161 | 162 | ``` 163 | 164 | ## Use the UUID within the headers 165 | 166 | Using the uuid `a66b19e4-3985-42a1-87e1-338707c4a203` we can now use the header 167 | that maps the user with the schema. 168 | 169 | ```cURL 170 | curl --header "X_REQUEST_ID: a66b19e4-3985-42a1-87e1-338707c4a203" -v http://localhost:8000/my-view 171 | ``` 172 | -------------------------------------------------------------------------------- /docs/handlers.md: -------------------------------------------------------------------------------- 1 | # Handlers 2 | 3 | `djang-tenants-url` offers some handlers that facilitates the creation or update 4 | or creation of new domain, tenants and tenant users 5 | 6 | ## Table of Contents 7 | 8 | --- 9 | 10 | - [Handlers](#handlers) 11 | - [Table of Contents](#table-of-contents) 12 | - [handle_tenant](#handle_tenant) 13 | - [handle_domain](#handle_domain) 14 | - [handle_tenant_user](#handle_tenant_user) 15 | 16 | --- 17 | 18 | ## handle_tenant 19 | 20 | Creates a tenant and a schema for the same tenant. E.g.: 21 | 22 | ```python 23 | 24 | from django_tenants_url.handlers import handle_tenant 25 | 26 | tenant = handle_tenant( 27 | domain_url='myexample.com', tenant_name='mytenant', schema_name='myschema' 28 | ) 29 | 30 | 31 | ``` 32 | 33 | ## handle_domain 34 | 35 | Creates a domain for a given tenant. E.g.: 36 | 37 | ```python 38 | 39 | from django_tenants_url.handlers import handle_domain, handle_tenant 40 | 41 | tenant = handle_tenant( 42 | domain_url='myexample.com', tenant_name='mytenant', schema_name='myschema' 43 | ) 44 | 45 | domain = handle_domain( 46 | domain_url='myexample.com', tenant=tenant, is_primary=True 47 | ) 48 | 49 | ``` 50 | 51 | ## handle_tenant_user 52 | 53 | Creates a tenant user relation. E.g.: 54 | 55 | ```python 56 | 57 | from django_tenants_url.handlers import handle_tenant, handle_tenant_user 58 | from django.contrib.auth import get_user_model 59 | 60 | user = get_user_model().objects.get(email='foobar@example.com') 61 | 62 | tenant = handle_tenant( 63 | domain_url='myexample.com', tenant_name='mytenant', schema_name='myschema' 64 | ) 65 | 66 | # if is_active=True, that only facilitates which tenant is currently active 67 | # for the user. This can help filter schemas on logins, for example. 68 | tenant_user = handle_tenant_user( 69 | tenant=tenant, user=user, is_active=True 70 | ) 71 | 72 | 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django Tenants URL 2 | 3 | ![Build and Publish](https://github.com/tarsil/django-tenants-url/actions/workflows/main.yml/badge.svg) 4 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=tarsil_django-tenants-url&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=tarsil_django-tenants-url) 5 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=tarsil_django-tenants-url&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=tarsil_django-tenants-url) 6 | [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=tarsil_django-tenants-url&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=tarsil_django-tenants-url) 7 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=tarsil_django-tenants-url&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=tarsil_django-tenants-url) 8 | 9 | --- 10 | 11 | ## Table of Contents 12 | 13 | - [Django Tenants URL](#django-tenants-url) 14 | - [Table of Contents](#table-of-contents) 15 | - [About Django Tenants URL](#about-django-tenants-url) 16 | - [Dependencies](#dependencies) 17 | - [Motivation](#motivation) 18 | - [Installation](#installation) 19 | - [After installing django-tenants](#after-installing-django-tenants) 20 | - [Install django-tenants-url](#install-django-tenants-url) 21 | - [Django Tenants URL Settings](#django-tenants-url-settings) 22 | - [X_REQUEST_ID](#x_request_id) 23 | - [Example](#example) 24 | - [License](#license) 25 | 26 | --- 27 | 28 | ## About Django Tenants URL 29 | 30 | Django Tenants URL is a wrapper on the top of the `django-tenants` package that serves a different 31 | yet common use case, the multi-tenant implementation via HEADER and not using `sub domains`. 32 | 33 | A special thanks to the team behind [Django Tenants](https://github.com/django-tenants/django-tenants). 34 | 35 | ## Dependencies 36 | 37 | The project contains [views](./views.md), [permissions](./permissions.md), 38 | [models](./models.md) and more addons that can be used across projects. 39 | 40 | `django-tenants-url` is built on the top of the following dependencies: 41 | 42 | 1. [Django](https://www.djangoproject.com/) 43 | 2. [Django Rest Framework](https://www.django-rest-framework.org/) 44 | 3. [Django Tenants](https://django-tenants.readthedocs.io/en/latest/) 45 | 46 | ## Motivation 47 | 48 | When implementing multi tenancy architecture there are many factors to consider and cover and 49 | those were greatly approached by [django-tenants](https://github.com/django-tenants/django-tenants) 50 | but so summarize, there are 3 common ways: 51 | 52 | 1. **Shared schemas** - The data of all users are shared within the same schema and filtered by 53 | common IDs or whatever that is unique to the platform. This is not so great for GDPR. 54 | 2. **Shared database, different Schemas** - The user's data is split by different schemas but live 55 | on the same database. 56 | 3. **Different databases** - The user's data or any data live on different databases. 57 | 58 | As mentioned before, `django-tenants-url` is a wrapper on the top of `django-tenants` 59 | and therefore we will be approaching the second. 60 | 61 | Many companies have limited resources (money, people...) and limited choices from those 62 | constraints. When implementing multi tenancy, the default would be to use subdomains 63 | in order to access the desired schema. E.g.: 64 | 65 | ```shell 66 | www.mycompany.saascompany.com 67 | ``` 68 | 69 | `My Company` is the tenant of the `saascompany.com` that is publicaly available to the users. 70 | When the `mycompany` is sent to the backend, the middleware splits the subdomain and 71 | the TLD (top-level domain) and maps the tenant with the schema associated. 72 | 73 | For this work, one of the ways is to change your `apache`, `nginx` or any other configurations 74 | that accepts and forwards calls to the `*.sasscompany.com` and performs the above action. 75 | 76 | If the frontend and backend are split, extra configurations need also to be made on that 77 | front and all of this can be a pain. 78 | 79 | **What does django-tenants-url solve?** 80 | 81 | The principle of mapping users to the schemas remains the same but the way of doing it 82 | is what diverges from the rest. What if we were able to only use `www.sasscompany.com`, 83 | login as usual and automatically the platform knows exactly to which schema the user 84 | needs to be mapped and forward? 85 | 86 | **This is what django-tenants-url solves. A single url that does the multi tenancy 87 | without breaking the principle and architecture and simply using one single url** 88 | 89 | ## Installation 90 | 91 | Prior to install the `django-tenants-url`, `django-tenants` needs to be installed 92 | as well. Please follow the installation steps from 93 | [Django Tenants](https://www.github.com/django-tenants/django-tenants) 94 | 95 | ### After installing django-tenants 96 | 97 | #### Install django-tenants-url 98 | 99 | ```shell 100 | pip install django-tenants-url 101 | ``` 102 | 103 | 1. The `TENANT_MODEL` and `TENANT_DOMAIN_MODEL` from `django-tenants` 104 | need to be also in the `settings.py`. 105 | 2. Add `django-tenants-url` to `INSTALLED_APPS`. 106 | 107 | ```python 108 | 109 | INSTALLED_APPS = [ 110 | ... 111 | 'django_tenants', 112 | 'django_tenants_url', 113 | ... 114 | ] 115 | 116 | ``` 117 | 118 | 3. `django-tenants-url` offers a special wrapper over the `mixins` of `django-tenants` 119 | with the same names so you don't need to worry about breaking changes and the 120 | additional unique `TenantUserMixin` that maps the users with a tenant. 121 | 122 | 4. Create the models. 123 | 124 | ```python 125 | # myapp.models.py 126 | 127 | from django.db import models 128 | from django_tenants_url.models import TenantMixin, DomainMixin, TenantUserMixin 129 | 130 | 131 | class Client(TenantMixin): 132 | """ 133 | This table provides the `tenant_uuid` needed 134 | to be used in the `X_REQUEST_HEADER` and consumed 135 | by the RequestUUIDTenantMiddleware. 136 | """ 137 | pass 138 | 139 | 140 | class Domain(DomainMixin): 141 | pass 142 | 143 | 144 | class TenantUser(TenantUserMixin): 145 | pass 146 | 147 | ``` 148 | 149 | 5. Add the `DTU_TENANT_USER_MODEL` to `settings.py`. 150 | 151 | ```python 152 | # settings.py 153 | 154 | ... 155 | 156 | DTU_TENANT_USER_MODEL = 'myapp.TenantUser' 157 | 158 | ... 159 | 160 | ``` 161 | 162 | 6. Update the `MIDDLEWARE` to have the new `RequestUUIDTenantMiddleware`. 163 | Preferentially at the very top. 164 | 165 | ```python 166 | # settings.py 167 | 168 | ... 169 | 170 | MIDDLEWARE = [ 171 | 'django_tenants_url.middleware.RequestUUIDTenantMiddleware', 172 | 'django.middleware.security.SecurityMiddleware', 173 | ... 174 | ] 175 | 176 | ... 177 | ``` 178 | 179 | 7. Generate the migrations. 180 | 181 | ```shell 182 | python manage.py makemigrations 183 | ``` 184 | 185 | 8. Run the migrations as if it was `django-tenants` and not the classic `migrate`. 186 | 187 | ```shell 188 | python manage.py migrate_schemas 189 | ``` 190 | 191 | 9. The `UUID` needed for the `RequestUUIDTenantMiddleware` can be found in your 192 | table inherited from the `TenantMixin`. 193 | 194 | **None: Do not run `python manage.py migrate` or else it will sync everything into the public.** 195 | 196 | And that is it. The `RequestUUIDTenantMiddleware` should be able to map 197 | the `TenantUser` created with a tenant and route the queries to the associated schema. 198 | 199 | ### Django Tenants URL Settings 200 | 201 | ```python 202 | # default settings 203 | 204 | DTU_TENANT_NAME = "Public" 205 | DTU_TENANT_SCHEMA = "public" 206 | DTU_DOMAIN_NAME = "localhost" 207 | DTU_PAID_UNTIL = "2100-12-31" 208 | DTU_ON_TRIAL = False 209 | DTU_HEADER_NAME = "HTTP_X_REQUEST_ID" 210 | DTU_AUTO_CREATE_SCHEMA = True 211 | DTU_AUTO_DROP_SCHEMA = False 212 | DTU_TENANT_USER_MODEL = None 213 | 214 | ``` 215 | 216 | ### X_REQUEST_ID 217 | 218 | By default `django-tenants-url` has the header name `HTTP_X_REQUEST_ID` that will be lookup 219 | from the middleware when sent via HTTP. 220 | This name can be overriten by the special setting `DTU_HEADER_NAME`. 221 | 222 | ## Example 223 | 224 | A Django Like app implementing Django Tenants Url can be found [here](https://github.com/tarsil/django-tenants-url/tree/main/dtu_test_project). 225 | 226 | The example can be found [here](./example.md) 227 | 228 | ## License 229 | 230 | Copyright (c) 2022-present Tiago Silva and contributors under the [MIT license](https://opensource.org/licenses/MIT). 231 | -------------------------------------------------------------------------------- /docs/middleware.md: -------------------------------------------------------------------------------- 1 | # Middleware 2 | 3 | The core of `django-tenants-url` is the custom `MIDDLEWARE`. 4 | 5 | ## Table of Contents 6 | 7 | --- 8 | 9 | - [Middleware](#middleware) 10 | - [Table of Contents](#table-of-contents) 11 | - [Installation](#installation) 12 | 13 | --- 14 | 15 | ## Installation 16 | 17 | 1. Add the `TENANT_USER_MODEL` to `settings.py`. 18 | 19 | ```python 20 | # settings.py 21 | 22 | ... 23 | 24 | DTU_TENANT_USER_MODEL = 'myapp.TenantUser' 25 | 26 | ... 27 | 28 | ``` 29 | 30 | 2. Update the `MIDDLEWARE` to have the new `RequestUUIDTenantMiddleware`. 31 | Preferentially at the very top. 32 | 33 | ```python 34 | # settings.py 35 | 36 | ... 37 | 38 | MIDDLEWARE = [ 39 | 'django_tenants_url.middleware.RequestUUIDTenantMiddleware', 40 | 'django.middleware.security.SecurityMiddleware', 41 | ... 42 | ] 43 | 44 | ... 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/models.md: -------------------------------------------------------------------------------- 1 | # Models 2 | 3 | `django-tenants` offers a set of models that can and should be use to facilitate 4 | the integration of multi-tenancy. 5 | 6 | `django-tenants-url` wraps those same models and adds some extra flavours to make 7 | sure it serves the purpose of a unified url for all tenants. 8 | 9 | --- 10 | 11 | ## Table of Contents 12 | 13 | - [Models](#models) 14 | - [Table of Contents](#table-of-contents) 15 | - [TenantMixin](#tenantmixin) 16 | - [DomainMixin](#domainmixin) 17 | - [TenantUserMixin](#tenantusermixin) 18 | - [Handlers](#handlers) 19 | 20 | --- 21 | 22 | ## TenantMixin 23 | 24 | 1. `TenantMixin` is where the tenant information is stored. 25 | 26 | ```python 27 | from django_tenants_url.models import TenantMixin 28 | 29 | 30 | class Tenant(TenantMixin): 31 | # Customize, override or leave it be 32 | pass 33 | 34 | ``` 35 | 36 | ## DomainMixin 37 | 38 | 1. `DomainMixin` is where the domain of a tenant is stored. 39 | 40 | ```python 41 | from django_tenants_url.models import DomainMixin 42 | 43 | 44 | class Domain(DomainMixin): 45 | # Customize, override or leave it be 46 | pass 47 | 48 | ``` 49 | 50 | ## TenantUserMixin 51 | 52 | 1. `TenantUserMixin` is where the mapping between a tenant and a user of the system 53 | is stored. This is one of the main differences that diverges from `django-tenants` and 54 | what allows the integration of a unified URL for all users and tenants in the system. 55 | 56 | ```python 57 | from django_tenants_url.models import TenantUserMixin 58 | 59 | 60 | class TenantUser(TenantUserMixin): 61 | # Customize, override or leave it be 62 | pass 63 | 64 | ``` 65 | 66 | ## Handlers 67 | 68 | To help with the process of the creation of a domain and tenant we can 69 | also use `handle_domain` and `handle_tenant`. [More on this here](./handlers.md). 70 | -------------------------------------------------------------------------------- /docs/permissions.md: -------------------------------------------------------------------------------- 1 | # Permissions 2 | 3 | Handling access permissions with `django-tenants-url` is like 4 | doing using Django Rest Framework. 5 | 6 | Django Tenants URL splits the middleware only used to map the UUID 7 | of a tenant and route the connection and the permissions to access 8 | a given tenant by validating the UUID of the header. 9 | 10 | ## Table of Contents 11 | 12 | --- 13 | 14 | - [Permissions](#permissions) 15 | - [Table of Contents](#table-of-contents) 16 | - [BaseTenantPermission](#basetenantpermission) 17 | - [IsTenantAllowedOrPublic](#istenantallowedorpublic) 18 | 19 | --- 20 | 21 | ## BaseTenantPermission 22 | 23 | The base used for the permissions of the tenants. 24 | 25 | ## IsTenantAllowedOrPublic 26 | 27 | Checks if the user is allowed to access a specific tenant. 28 | If no header is passed, it will return `True` defaulting to public. 29 | 30 | When a header (default `X_REQUEST_ID`) is passed then checks if the 31 | user has permission to access it, in other words, checks if there 32 | is a tenant user (`TenantUserMixin`) associated. 33 | 34 | Example: 35 | 36 | ```python 37 | # views.py 38 | 39 | from django_tenants_url.permissions import IsTenantAllowedOrPublic 40 | from rest_framwork.generics import ListAPIView 41 | from rest_framework.permissions import IsAuthenticated 42 | from myapp.models import Customer 43 | 44 | 45 | class MyView(ListAPIView): 46 | permission_classes = [IsAuthenticated, IsTenantAllowedOrPublic] 47 | 48 | def get(self, request, *args, **kwargs): 49 | return Customer.objects.all() 50 | 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/releases.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## 1.0.1 4 | 5 | - Fix code smells on serializers and permissions. 6 | 7 | ## 1.0.0 8 | 9 | - Release of `django-tenants-url`. 10 | - Middleware 11 | - Models 12 | - Handlers 13 | - Permissions 14 | - Views 15 | - URLs 16 | - Utils 17 | -------------------------------------------------------------------------------- /docs/serializers.md: -------------------------------------------------------------------------------- 1 | # Serializers 2 | 3 | There are built-in serializers offered by the package. 4 | 5 | ## Table of Contents 6 | 7 | --- 8 | 9 | - [Serializers](#serializers) 10 | - [Table of Contents](#table-of-contents) 11 | - [Notes](#notes) 12 | - [TenantSerializer](#tenantserializer) 13 | - [TenantListSerializer](#tenantlistserializer) 14 | - [CreateTenantSerializer](#createtenantserializer) 15 | - [TenantUserSerializer](#tenantuserserializer) 16 | 17 | --- 18 | 19 | ## Notes 20 | 21 | The following serializers are used with the [views](./views.md) but can also 22 | be used with any other custom view. 23 | 24 | --- 25 | 26 | ## TenantSerializer 27 | 28 | Basic Tenant Serializer 29 | 30 | - Renders: 31 | - `tenant_name` - String 32 | - `tenant_uuid` - String 33 | 34 | Example: 35 | 36 | ```python 37 | from django_tenants_url.serializers import TenantSerializer 38 | from rest_framework.views import APIView 39 | from django_tenants.utils import get_tenant_model 40 | 41 | 42 | class MyView(APIView): 43 | serializer_class = TenantSerializer 44 | 45 | def post(self, request, *args, **kwargs): 46 | data = request.data or {} 47 | tenant_name = data.get("tenant_name") 48 | 49 | if tenant_name is None: 50 | raise ParseError(detail="tenant_name missing.") 51 | 52 | try: 53 | tenant = get_tenant_model().objects.get(tenant_name__iexact=tenant_name) 54 | except get_tenant_model().DoesNotExist: 55 | raise ParseError(detail=f"{tenant_name} does not exist.") 56 | 57 | serializer = self.serializer_class(tenant, many=False) 58 | return Response(serializer.data, status=status.HTTP_200_OK) 59 | ``` 60 | 61 | ## TenantListSerializer 62 | 63 | Model serializer retrieving the information of the tenants. 64 | 65 | - Renders: 66 | - `id` - Integer 67 | - `tenant_name` - String 68 | - `tenant_uuid` - String 69 | 70 | Example: 71 | 72 | ```python 73 | from django_tenants_url.serializers import TenantListSerializer 74 | from rest_framework.generics import ListAPIView 75 | from django_tenants.utils import get_tenant_model 76 | 77 | 78 | class MyView(ListAPIView): 79 | serializer_class = TenantListSerializer 80 | queryset = get_tenant_model().objects.all() 81 | 82 | ``` 83 | 84 | ## CreateTenantSerializer 85 | 86 | For the creation of a tenant in the system. 87 | 88 | - **Params**: 89 | - `tenant_name` - String (required). 90 | - `schema_name` - String (required). 91 | - `domain_url` - String (required). 92 | - `on_trial` - Boolean (optional, `default=False`). 93 | - `paid_until` - DateTime (optional, raises exception if `on_trial=True` and `paid_until=None`). 94 | - `is_primary` - Boolean (optional, `default=True`). 95 | 96 | [Implementation example](./views.md#createtenantapiview). 97 | 98 | ## TenantUserSerializer 99 | 100 | For the creation of a tenant user association. 101 | 102 | - **Params**: 103 | - `user_id` - Integer (required). 104 | - `tenant_id` - Integer (required). 105 | - `is_active` - Boolean (required). 106 | 107 | [Implementation example](./views.md#tenantuserapiview). 108 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | Default settings are provided with `django-tenants-url`. 4 | 5 | ## Table of Contents 6 | 7 | --- 8 | 9 | - [Settings](#settings) 10 | - [Table of Contents](#table-of-contents) 11 | - [Defaults](#defaults) 12 | - [HEADER_NAME](#header_name) 13 | - [TENANT_USER_MODEL](#tenant_user_model) 14 | - [Example](#example) 15 | 16 | --- 17 | 18 | ## Defaults 19 | 20 | ```python 21 | # settings.py 22 | ... 23 | 24 | DTU_TENANT_NAME = "Public" 25 | DTU_TENANT_SCHEMA = "public" 26 | DTU_DOMAIN_NAME = "localhost" 27 | DTU_PAID_UNTIL = "2100-12-31" 28 | DTU_ON_TRIAL = False 29 | DTU_HEADER_NAME = "HTTP_X_REQUEST_ID" 30 | DTU_AUTO_CREATE_SCHEMA = True 31 | DTU_AUTO_DROP_SCHEMA = False 32 | DTU_TENANT_USER_MODEL = None 33 | 34 | ``` 35 | 36 | - `DTU_TENANT_NAME` - Default tenant name for the public schema. 37 | - `DTU_TENANT_SCHEMA` - Default schema name for the public. 38 | - `DTU_DOMAIN_NAME` - Public schema defaults to localhost (production server, for example). 39 | - `DTU_PAID_UNTIL` - Default paid_until date. The package can be used for subscription models. 40 | - `DTU_ON_TRIAL` - Defaults to `false`. With `paid_until` this flag can be activated 41 | for subscription models. 42 | - `DTU_HEADER_NAME` - Name of the header to be sent with the calls and route to the schema. 43 | - `DTU_AUTO_CREATE_SCHEMA` - When disabled, a tenant schema is not created on `save()`. 44 | - `DTU_AUTO_DROP_SCHEMA` - If disabled, a tenant when removed the schema is not delated on `delete()`. 45 | - `DTU_TENANT_USER_MODEL` - Required field pointing to the model mapping a tenant with a user. 46 | 47 | ## HEADER_NAME 48 | 49 | Probably the one of the most important configurations. 50 | The `HEADER_NAME` is what will be used to be read/sent from the requests hitting the back-end server 51 | and map the current user with a schema. 52 | 53 | This field can be overritten to any value of choice **but be careful when changing**. 54 | 55 | ## TENANT_USER_MODEL 56 | 57 | This field is **crucial** to be updated in the settings ([see installation instructions](./index.md)). 58 | 59 | The model will facilitate the mapping between a tenant and a user. 60 | The [middleware](./middleware.md) doesn't lookup at this model but it's specially useful 61 | if the [permissions](./permissions.md) are used. 62 | 63 | The [middleware](./middleware.md) looks at the [Tenant model](./models.md#tenantmixin). 64 | 65 | ## Example 66 | 67 | When updating the settings, specially `HEADER_NAME` that can be done via `settings.py`. 68 | 69 | ```python 70 | # settings.py 71 | 72 | ... 73 | 74 | DTU_HEADER_NAME = `HTTP_X_UNIQUE_ID" 75 | 76 | ``` 77 | 78 | ```cURL 79 | curl --header "X_UNIQUE_ID: " -v http://localhost:8000/my-view 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/urls.md: -------------------------------------------------------------------------------- 1 | # URLS 2 | 3 | ## Table of Contents 4 | 5 | --- 6 | 7 | - [URLS](#urls) 8 | - [Table of Contents](#table-of-contents) 9 | - [How to import](#how-to-import) 10 | - [Available URLS](#available-urls) 11 | 12 | --- 13 | 14 | ## How to import 15 | 16 | ```python 17 | # urls.py 18 | from django.urls import include 19 | 20 | ... 21 | 22 | urlpatterns = [ 23 | ... 24 | path('tenants', include('django_tenants_url.urls')) 25 | ... 26 | ] 27 | 28 | ``` 29 | 30 | ## Available URLS 31 | 32 | ```python 33 | 34 | urlpatterns = [ 35 | path("info", TenantAPIView.as_view(), name="tenant-info"), 36 | path("tenant", CreateTenantAPIView.as_view(), name="create-tenant"), 37 | path( 38 | "tenant/delete/", DestroyTenantAPIView.as_view(), name="delete-tenant" 39 | ), 40 | path("tenants", TenantListAPIView.as_view(), name="tenant-list"), 41 | path("tenant/user", TenantUserAPIView.as_view(), name="create-tenant-user"), 42 | path( 43 | "tenant/user//", 44 | DestroyTenantUserAPIView.as_view(), 45 | name="delete-tenant-user", 46 | ), 47 | path( 48 | "tenant/user//set-active", 49 | SetActiveTenantUserAPIView.as_view(), 50 | name="set-active-tenant", 51 | ), 52 | ] 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/utils.md: -------------------------------------------------------------------------------- 1 | # Utils 2 | 3 | ## Table of Contents 4 | 5 | --- 6 | 7 | - [Utils](#utils) 8 | - [Table of Contents](#table-of-contents) 9 | - [Functions](#functions) 10 | 11 | --- 12 | 13 | ## Functions 14 | 15 | 1. `get_tenant_user_model()` - Return the Tenant User model set 16 | in the settings `DTU_TENANT_USER_MODEL`. 17 | 2. `get_active_user_tenant()` - Return the active tenant of a given `auth.User`. 18 | 3. `get_user_tenants()` - Lists the tenants associated with a user. 19 | 4. `get_tenants()` - Returns a list of all tenants. 20 | 21 | ```python 22 | from django_tenants_url.utils import ( 23 | get_tenant_user_model, 24 | get_active_user_tenant, 25 | get_user_tenants, 26 | get_tenants 27 | ) 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/views.md: -------------------------------------------------------------------------------- 1 | # Views 2 | 3 | Using multi tenancy can be complicated sometimes and the initial 4 | configurations can take some time to get around. 5 | 6 | `django-tenants-url` has some built-in views to help with the process 7 | but those can be replaced by any custom view. 8 | 9 | The views are built on the top of [Django Rest Framework](https://www.django-rest-framework.org/). 10 | 11 | ## Table of Contents 12 | 13 | --- 14 | 15 | - [Views](#views) 16 | - [Table of Contents](#table-of-contents) 17 | - [Initial Notes](#initial-notes) 18 | - [Inherited Views](#inherited-views) 19 | - [Import Views](#import-views) 20 | - [Import URLs](#import-urls) 21 | - [TenantListAPIView](#tenantlistapiview) 22 | - [TenantAPIView](#tenantapiview) 23 | - [CreateTenantAPIView](#createtenantapiview) 24 | - [DestroyTenantAPIView](#destroytenantapiview) 25 | - [TenantUserAPIView](#tenantuserapiview) 26 | - [DestroyTenantUserAPIView](#destroytenantuserapiview) 27 | - [SetActiveTenantUserAPIView](#setactivetenantuserapiview) 28 | 29 | --- 30 | 31 | ## Initial Notes 32 | 33 | The views below are built-in from the `django-tenants-url` and those can be used directly: 34 | 35 | - [Per Mixin](#inherited-views) 36 | - [Per import](#import-views) 37 | - [Per URL](#import-urls) 38 | 39 | The [urls](./urls.md) can also be imported and used directly. 40 | 41 | ## Inherited Views 42 | 43 | One way to use the views is by simply inheriting to be used with any system and permissions. 44 | 45 | Example: 46 | 47 | ```python 48 | # views.py 49 | 50 | ... 51 | from django_tenants_url.views import CreateTenantAPIView 52 | ... 53 | 54 | 55 | class MyCustomView(MyCustomAuthMixin, CreateTenantAPIView): 56 | permission_classes = [IsAuthenticated, MyCustomTenantPermission] 57 | 58 | ... 59 | 60 | ``` 61 | 62 | ## Import Views 63 | 64 | To import all the built-in views from the project, simply use the standard imports. 65 | 66 | Example.: 67 | 68 | ```python 69 | from django_tenants_url.views import ( 70 | CreateTenantAPIView, 71 | DestroyTenantAPIView, 72 | DestroyTenantUserAPIView, 73 | SetActiveTenantUserAPIView, 74 | TenantAPIView, 75 | TenantListAPIView, 76 | TenantUserAPIView 77 | ) 78 | 79 | ... 80 | ``` 81 | 82 | ## Import URLs 83 | 84 | The views can be directly imported into a project via normal django url imports. 85 | 86 | Example: 87 | 88 | ```python 89 | # urls.py 90 | 91 | from django.urls import include 92 | 93 | urlpatterns = [ 94 | ... 95 | path('tenants', include('django_tenants_url.urls')), 96 | ... 97 | ] 98 | 99 | ``` 100 | 101 | --- 102 | 103 | ## TenantListAPIView 104 | 105 | Lists all the tenants in the system. No params required. 106 | 107 | - Method: `GET`. 108 | 109 | Example: 110 | 111 | ```python 112 | import requests 113 | 114 | url = 'http://localhost:8000/api/v1/myview 115 | 116 | response = requests.get(url) 117 | 118 | ``` 119 | 120 | ## TenantAPIView 121 | 122 | Returns the information of a given tenant. 123 | 124 | - Method: `POST`. 125 | - **Params**: 126 | - `tenant_name` - String (required) 127 | 128 | Example: 129 | 130 | ```python 131 | import requests 132 | 133 | url = 'http://localhost:8000/api/v1/myview 134 | params = { 135 | 'tenant_name': 'Public' 136 | } 137 | 138 | response = requests.post(url, params=params) 139 | 140 | ``` 141 | 142 | ## CreateTenantAPIView 143 | 144 | Creates a tenant and corresponding schema in the system. 145 | 146 | - Method: `POST`. 147 | - **Params**: 148 | - `tenant_name` - String (required). 149 | - `schema_name` - String (required). 150 | - `domain_url` - String (required). 151 | 152 | Example: 153 | 154 | ```python 155 | import requests 156 | 157 | url = 'http://localhost:8000/api/v1/myview 158 | params = { 159 | 'tenant_name': 'My Company', 160 | 'schema_name': 'my_company', 161 | 'domain_url': 'mycompany.com' 162 | } 163 | 164 | response = requests.post(url, params=params) 165 | 166 | ``` 167 | 168 | ## DestroyTenantAPIView 169 | 170 | Removes a tenant and domains associated to the tenant from the system. 171 | 172 | - Method: `DELETE`. 173 | - **Params**: 174 | - `id` - Integer (URL parameter, required). 175 | 176 | Example: 177 | 178 | ```python 179 | import requests 180 | 181 | url = 'http://localhost:8000/api/v1/myview/2 182 | 183 | response = requests.delete(url) 184 | 185 | ``` 186 | 187 | ## TenantUserAPIView 188 | 189 | Associates a system user with a system tenant and sets to active. 190 | 191 | - Method: `POST`. 192 | - **Params**: 193 | - `user_id` - Integer (URL parameter, required). 194 | - `tenant_id` - Integer (URL parameter, required). 195 | - `is_active` - Boolean (URL parameter, required). 196 | 197 | Example: 198 | 199 | ```python 200 | import requests 201 | 202 | url = 'http://localhost:8000/api/v1/myview 203 | params = { 204 | 'user_id': 12, 205 | 'tenant_id': 25, 206 | 'is_active': True 207 | } 208 | 209 | response = requests.post(url, params=params) 210 | 211 | ``` 212 | 213 | ## DestroyTenantUserAPIView 214 | 215 | Removes the association of a system user and a tenant. 216 | 217 | - Method: `DELETE`. 218 | - **Params**: 219 | - `user_id` - Integer (URL parameter, required). 220 | - `tenant_id` - Integer (URL parameter, required). 221 | 222 | Example: 223 | 224 | ```python 225 | import requests 226 | 227 | url = 'http://localhost:8000/api/v1/myview/12/25 228 | 229 | response = requests.delete(url) 230 | 231 | ``` 232 | 233 | ## SetActiveTenantUserAPIView 234 | 235 | Sets the current active tenant of a request user (logged in). 236 | 237 | - Method: `PUT`. 238 | - **Params**: 239 | - `tenant_id` - Integer (URL parameter, required). 240 | 241 | Example: 242 | 243 | ```python 244 | import requests 245 | 246 | url = 'http://localhost:8000/api/v1/myview/25 247 | 248 | response = requests.put(url) 249 | 250 | ``` 251 | -------------------------------------------------------------------------------- /dtu_test_project/Makefile: -------------------------------------------------------------------------------- 1 | DEFAULT_GOAL := help 2 | 3 | .PHONY: help 4 | help: 5 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 6 | 7 | .PHONY: clean 8 | clean: clean_pyc 9 | 10 | .PHONY: clean_pyc 11 | clean_pyc: ## Clean all *.pyc in the system 12 | find . -type f -name "*.pyc" -delete || true 13 | 14 | .PHONY: migrate 15 | migrate: # Runs the migrations 16 | python manage.py migrate_schemas 17 | 18 | .PHONY: migrations 19 | migrations: ## Generate migrations 20 | python manage.py makemigrations 21 | 22 | .PHONY: requirements 23 | requirements-deployment: ## Install the requirements 24 | pip install -r requirements.txt 25 | 26 | .PHONY: test 27 | test: ## Runs the unit tests from the scratch by recreating the testing database 28 | pytest $(TESTONLY) --disable-pytest-warnings -s -vv $(DB) 29 | 30 | .PHONY: show_urls 31 | show_urls: 32 | python manage.py show_urls 33 | 34 | .PHONY: run 35 | run: 36 | python manage.py runserver_plus 0.0.0.0:8000 37 | 38 | .PHONY: shell 39 | shell: 40 | python manage.py shell_plus --settings=dymmond.development.settings 41 | 42 | .PHONY: start_docker 43 | start_docker: ## Starts the dev environment 44 | docker-compose up -d 45 | 46 | .PHONY: start_docker_logs 47 | start_docker_logs: ## Starts the dev environment 48 | docker-compose up 49 | 50 | ifndef VERBOSE 51 | .SILENT: 52 | endif 53 | -------------------------------------------------------------------------------- /dtu_test_project/customers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarsil/django-tenants-url/6758043f05fe2244d579adf59c38399ad871f71b/dtu_test_project/customers/__init__.py -------------------------------------------------------------------------------- /dtu_test_project/customers/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /dtu_test_project/customers/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CustomersConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'customers' 7 | -------------------------------------------------------------------------------- /dtu_test_project/customers/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-07-28 11:38 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django_tenants.postgresql_backend.base 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Client', 21 | fields=[ 22 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])), 24 | ('domain_url', models.URLField(blank=True, default=None, null=True)), 25 | ('tenant_name', models.CharField(max_length=100, unique=True)), 26 | ('tenant_uuid', models.UUIDField(default=uuid.uuid4)), 27 | ('paid_until', models.DateField(blank=True, null=True)), 28 | ('on_trial', models.BooleanField(blank=True, null=True)), 29 | ('created_on', models.DateField(auto_now_add=True)), 30 | ], 31 | options={ 32 | 'abstract': False, 33 | }, 34 | ), 35 | migrations.CreateModel( 36 | name='TenantUser', 37 | fields=[ 38 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 39 | ('is_active', models.BooleanField(default=False)), 40 | ('created_on', models.DateField(auto_now_add=True)), 41 | ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_users', to='customers.client')), 42 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_users', to=settings.AUTH_USER_MODEL)), 43 | ], 44 | options={ 45 | 'abstract': False, 46 | }, 47 | ), 48 | migrations.CreateModel( 49 | name='Domain', 50 | fields=[ 51 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 52 | ('domain', models.CharField(db_index=True, max_length=253, unique=True)), 53 | ('is_primary', models.BooleanField(db_index=True, default=True)), 54 | ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='customers.client')), 55 | ], 56 | options={ 57 | 'abstract': False, 58 | }, 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /dtu_test_project/customers/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarsil/django-tenants-url/6758043f05fe2244d579adf59c38399ad871f71b/dtu_test_project/customers/migrations/__init__.py -------------------------------------------------------------------------------- /dtu_test_project/customers/models.py: -------------------------------------------------------------------------------- 1 | from django_tenants_url.models import DomainMixin, TenantMixin, TenantUserMixin 2 | 3 | 4 | class Client(TenantMixin): 5 | pass 6 | 7 | 8 | class Domain(DomainMixin): 9 | pass 10 | 11 | 12 | class TenantUser(TenantUserMixin): 13 | pass 14 | -------------------------------------------------------------------------------- /dtu_test_project/customers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarsil/django-tenants-url/6758043f05fe2244d579adf59c38399ad871f71b/dtu_test_project/customers/tests/__init__.py -------------------------------------------------------------------------------- /dtu_test_project/customers/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | import factory.django 3 | from django.contrib.auth import get_user_model 4 | from django_tenants.utils import get_tenant_model 5 | 6 | from django_tenants_url.utils import get_tenant_user_model 7 | 8 | 9 | class UserFactory(factory.django.DjangoModelFactory): 10 | username = factory.Sequence(lambda n: "testes-%s" % n) 11 | password = factory.PostGenerationMethodCall("set_password", "testes") 12 | first_name = "Test" 13 | last_name = "User" 14 | email = factory.LazyAttribute(lambda u: "%s@testes.example.com" % u.username) 15 | 16 | class Meta: 17 | model = get_user_model() 18 | 19 | 20 | class TenantFactory(factory.django.DjangoModelFactory): 21 | tenant_name = factory.Sequence(lambda n: "Tenant - %s" % n) 22 | schema_name = factory.Sequence(lambda n: "tenant_%s" % n) 23 | 24 | class Meta: 25 | model = get_tenant_model() 26 | 27 | 28 | class TenantUserFactory(factory.django.DjangoModelFactory): 29 | user = factory.SubFactory(UserFactory) 30 | tenant = factory.SubFactory(TenantFactory) 31 | 32 | class Meta: 33 | model = get_tenant_user_model() 34 | -------------------------------------------------------------------------------- /dtu_test_project/customers/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import IntegrityError, connection 3 | from django_tenants.test.cases import FastTenantTestCase 4 | from django_tenants.utils import get_tenant_domain_model, get_tenant_model 5 | 6 | from django_tenants_url.utils import ( 7 | get_active_user_tenant, 8 | get_tenant_user_model, 9 | get_user_tenants, 10 | ) 11 | 12 | from .factories import TenantFactory, TenantUserFactory, UserFactory 13 | 14 | 15 | class BaseTest(FastTenantTestCase): 16 | def tearDown(self) -> None: 17 | connection.set_schema_to_public() 18 | get_user_model().objects.all().delete() 19 | get_tenant_model().objects.all().delete() 20 | get_tenant_domain_model().objects.all().delete() 21 | get_tenant_user_model().objects.all().delete() 22 | 23 | 24 | class TestTenantUser(BaseTest): 25 | def setUp(self) -> None: 26 | self.user = UserFactory() 27 | 28 | def test_can_create_tenant_user(self): 29 | tenant = TenantFactory() 30 | TenantUserFactory(user=self.user, tenant=tenant) 31 | 32 | total = get_tenant_user_model().objects.all() 33 | 34 | self.assertEqual(total.count(), 1) 35 | self.assertEqual(self.user.tenant_users.count(), 1) 36 | 37 | def test_active_schema_user(self): 38 | connection.set_schema_to_public() 39 | tenant = TenantFactory() 40 | tenant_user = TenantUserFactory(user=self.user, tenant=tenant, is_active=True) 41 | 42 | self.assertEqual( 43 | get_active_user_tenant(self.user)["tenant_uuid"], 44 | str(tenant_user.tenant.tenant_uuid), 45 | ) 46 | 47 | def test_can_be_tenant_of_multiple_customers(self): 48 | 49 | for i in range(3): 50 | tenant = TenantFactory() 51 | TenantUserFactory(user=self.user, tenant=tenant) 52 | 53 | self.assertEqual(len(get_user_tenants(self.user)), 3) 54 | 55 | def test_tenant_list(self): 56 | 57 | tenants = [] 58 | 59 | for i in range(3): 60 | tenant = TenantFactory() 61 | TenantUserFactory(user=self.user, tenant=tenant) 62 | tenants.append(str(tenant.tenant_uuid)) 63 | 64 | user_tenants = sorted( 65 | [value["tenant_uuid"] for value in get_user_tenants(self.user)] 66 | ) 67 | tenants = sorted(tenants) 68 | 69 | self.assertEqual(user_tenants, tenants) 70 | 71 | def test_multiple_tenants_one_active(self): 72 | tenant = TenantFactory() 73 | TenantUserFactory(user=self.user, tenant=tenant, is_active=True) 74 | 75 | self.assertEqual( 76 | get_active_user_tenant(self.user)["tenant_uuid"], str(tenant.tenant_uuid) 77 | ) 78 | 79 | tenant_2 = TenantFactory() 80 | TenantUserFactory(user=self.user, tenant=tenant_2) 81 | 82 | tenant_3 = TenantFactory() 83 | TenantUserFactory(user=self.user, tenant=tenant_3, is_active=True) 84 | 85 | self.assertEqual( 86 | get_active_user_tenant(self.user)["tenant_uuid"], str(tenant_3.tenant_uuid) 87 | ) 88 | self.assertNotEqual( 89 | get_active_user_tenant(self.user)["tenant_uuid"], str(tenant_2.tenant_uuid) 90 | ) 91 | self.assertNotEqual( 92 | get_active_user_tenant(self.user)["tenant_uuid"], str(tenant.tenant_uuid) 93 | ) 94 | -------------------------------------------------------------------------------- /dtu_test_project/customers/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /dtu_test_project/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | db: 4 | restart: always 5 | image: postgres:12.3 6 | environment: 7 | POSTGRES_HOST_AUTH_METHOD: trust 8 | POSTGRES_DB: dtu_test_project 9 | POSTGRES_PASSWORD: root 10 | POSTGRES_USER: postgres 11 | expose: 12 | - "5432" 13 | volumes: 14 | - "dtu_test_db:/var/lib/postgresql/data" 15 | ports: 16 | - "5432:5432" 17 | 18 | volumes: 19 | dtu_test_db: 20 | external: true 21 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarsil/django-tenants-url/6758043f05fe2244d579adf59c38399ad871f71b/dtu_test_project/dtu_test/__init__.py -------------------------------------------------------------------------------- /dtu_test_project/dtu_test/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for dtu_test project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dtu_test.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for dtu_test project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.13. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | import os 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-(&!)c#w0ehia+_8gy6th!+(@#&3r32hq5k92fraheu$^90lqnd" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | BASE_INSTALLED_APPS = [ 34 | "django_tenants", 35 | "django_tenants_url", 36 | "django.contrib.admin", 37 | "django.contrib.auth", 38 | "django.contrib.contenttypes", 39 | "django.contrib.sessions", 40 | "django.contrib.messages", 41 | "django.contrib.staticfiles", 42 | "django_extensions", 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | "django_tenants_url.middleware.RequestUUIDTenantMiddleware", 47 | "django.middleware.security.SecurityMiddleware", 48 | "django.contrib.sessions.middleware.SessionMiddleware", 49 | "django.middleware.common.CommonMiddleware", 50 | "django.middleware.csrf.CsrfViewMiddleware", 51 | "django.contrib.auth.middleware.AuthenticationMiddleware", 52 | "django.contrib.messages.middleware.MessageMiddleware", 53 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 54 | ] 55 | 56 | INSTALLED_APPS = ["customers", "dtu_test_app"] + BASE_INSTALLED_APPS 57 | 58 | # What is common and non-tentant-specific 59 | SHARED_APPS = INSTALLED_APPS 60 | 61 | TENANT_APPS = [ 62 | "django.contrib.contenttypes", 63 | "dtu_test_app", 64 | "dtu_multi_type2", 65 | ] 66 | 67 | TENANT_MODEL = "customers.Client" # app.Model 68 | 69 | TENANT_DOMAIN_MODEL = "customers.Domain" # app.Model 70 | 71 | DTU_TENANT_USER_MODEL = "customers.TenantUser" 72 | 73 | ROOT_URLCONF = "dtu_test.urls" 74 | 75 | TEMPLATES = [ 76 | { 77 | "BACKEND": "django.template.backends.django.DjangoTemplates", 78 | "DIRS": [], 79 | "APP_DIRS": True, 80 | "OPTIONS": { 81 | "context_processors": [ 82 | "django.template.context_processors.debug", 83 | "django.template.context_processors.request", 84 | "django.contrib.auth.context_processors.auth", 85 | "django.contrib.messages.context_processors.messages", 86 | ], 87 | }, 88 | }, 89 | ] 90 | 91 | WSGI_APPLICATION = "dtu_test.wsgi.application" 92 | 93 | DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) 94 | 95 | DATABASES = { 96 | "default": { 97 | "ENGINE": "django_tenants.postgresql_backend", 98 | "NAME": os.environ.get("DATABASE_DB", "dtu_test_project"), 99 | "USER": os.environ.get("DATABASE_USER", "postgres"), 100 | "PASSWORD": os.environ.get("DATABASE_PASSWORD", "root"), 101 | "HOST": os.environ.get("DATABASE_HOST", "localhost"), 102 | "PORT": os.environ.get("DATABASE_PORT", 5432), 103 | } 104 | } 105 | 106 | 107 | # Database 108 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 109 | 110 | # DATABASES = { 111 | # "default": { 112 | # "ENGINE": "django.db.backends.sqlite3", 113 | # "NAME": BASE_DIR / "db.sqlite3", 114 | # } 115 | # } 116 | 117 | 118 | # Password validation 119 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 120 | 121 | AUTH_PASSWORD_VALIDATORS = [ 122 | { 123 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 124 | }, 125 | { 126 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 127 | }, 128 | { 129 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 130 | }, 131 | { 132 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 133 | }, 134 | ] 135 | 136 | 137 | # Internationalization 138 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 139 | 140 | LANGUAGE_CODE = "en-us" 141 | 142 | TIME_ZONE = "UTC" 143 | 144 | USE_I18N = True 145 | 146 | USE_L10N = True 147 | 148 | USE_TZ = True 149 | 150 | 151 | # Static files (CSS, JavaScript, Images) 152 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 153 | 154 | STATIC_URL = "/static/" 155 | 156 | # Default primary key field type 157 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 158 | 159 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 160 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test/testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarsil/django-tenants-url/6758043f05fe2244d579adf59c38399ad871f71b/dtu_test_project/dtu_test/testing/__init__.py -------------------------------------------------------------------------------- /dtu_test_project/dtu_test/testing/databases.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django_tenants.postgresql_backend", 8 | "NAME": "dtu_test_project", 9 | "USER": "postgres", 10 | "PASSWORD": "root", 11 | "HOST": "localhost", 12 | "PORT": "5432", 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test/testing/settings.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | """ 3 | testing.settings will pull in (probably global) local_settings, 4 | This is a special thanks to David Dyball for helping me understand and build something very familiar to me 5 | in terms of settings and how to set them up 6 | To all who contribute for this, thank you very much. 7 | If you are using windows by default, the permissions to access subfolders for tests are disabled 8 | Activate them using NOSE_INCLUDE_EXE = 1 or an environment variable in your OS with the same name and value 9 | """ 10 | import os 11 | 12 | from ..settings import * 13 | from .databases import * 14 | 15 | SECRET_KEY = os.environ.get( 16 | "SECRET_KEY", "x7@y+)ixs_gdewzjw!br7ee#e4ovat7xd3%5&m8i6ws(d=5p#x" 17 | ) 18 | 19 | # 20 | # Other settings 21 | # 22 | DEBUG = True 23 | TESTING = True 24 | 25 | # 26 | # Tells the django environment 27 | # 28 | DJANGOENV = os.environ.get("DJANGOENV", "testing") 29 | 30 | REUSE_DB = bool(int(os.environ.get("REUSE_DB", 0))) 31 | 32 | if REUSE_DB: 33 | DATABASE_ROUTERS = [] 34 | 35 | # Disable the Secure SSL Redirect (special thanks to @DD) 36 | SECURE_SSL_REDIRECT = False 37 | 38 | # Use this if you have local_settings.pt file 39 | DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" 40 | MEDIA_URL = "/media/" 41 | STATIC_ROOT = "/tmp/assets-upload" 42 | STATIC_URL = "/static/" 43 | MEDIA_ROOT = "/tmp/media-root" 44 | 45 | # We don't want to run Memcached for tests. 46 | SESSION_ENGINE = "django.contrib.sessions.backends.db" 47 | 48 | CACHES = { 49 | "default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}, 50 | "staticfiles": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}, 51 | } 52 | 53 | LOGGING = { 54 | "version": 1, 55 | "disable_existing_loggers": True, 56 | "root": {"level": "INFO", "handlers": ["console"]}, 57 | "formatters": { 58 | "verbose": { 59 | "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" 60 | } 61 | }, 62 | "handlers": { 63 | "console": { 64 | "level": "INFO", 65 | "class": "logging.StreamHandler", 66 | "formatter": "verbose", 67 | } 68 | }, 69 | } 70 | 71 | MIDDLEWARE = list(MIDDLEWARE) 72 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test/urls.py: -------------------------------------------------------------------------------- 1 | """dtu_test URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | from dtu_test_app.views import ProductListAPIView, ProductTenantListAPIView 19 | 20 | urlpatterns = [ 21 | path("admin/", admin.site.urls), 22 | path("django-tenants-url/", include("django_tenants_url.urls")), 23 | # TEST ENDPOINTS 24 | path("product/list", ProductListAPIView.as_view(), name="product-list"), 25 | path( 26 | "product-tenant/list", 27 | ProductTenantListAPIView.as_view(), 28 | name="product-tenant-list", 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for dtu_test 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/3.2/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', 'dtu_test.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarsil/django-tenants-url/6758043f05fe2244d579adf59c38399ad871f71b/dtu_test_project/dtu_test_app/__init__.py -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DtuTestAppConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'dtu_test_app' 7 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-07-28 12:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Product', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=255)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarsil/django-tenants-url/6758043f05fe2244d579adf59c38399ad871f71b/dtu_test_project/dtu_test_app/migrations/__init__.py -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Product(models.Model): 5 | name = models.CharField(max_length=255, null=False, blank=False) 6 | 7 | def __str__(self): 8 | return f"{self.name}" 9 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/serializers.py: -------------------------------------------------------------------------------- 1 | from dtu_test_app.models import Product 2 | from rest_framework import serializers 3 | 4 | 5 | class ProductSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = Product 8 | fields = "__all__" 9 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarsil/django-tenants-url/6758043f05fe2244d579adf59c38399ad871f71b/dtu_test_project/dtu_test_app/tests/__init__.py -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | import factory.django 3 | from dtu_test_app.models import Product 4 | 5 | 6 | class ProductFactory(factory.django.DjangoModelFactory): 7 | name = factory.Sequence(lambda n: "Product -%s" % n) 8 | 9 | class Meta: 10 | model = Product 11 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from customers.tests.factories import TenantFactory, UserFactory 2 | from django.contrib.auth import get_user_model 3 | from django.db import IntegrityError, connection 4 | from django_tenants.test.cases import FastTenantTestCase 5 | from django_tenants.utils import get_tenant_domain_model, get_tenant_model 6 | from dtu_test_app.models import Product 7 | 8 | from django_tenants_url.utils import get_tenant_user_model 9 | 10 | from .factories import ProductFactory 11 | 12 | 13 | class BaseTest(FastTenantTestCase): 14 | def tearDown(self) -> None: 15 | Product.objects.all().delete() 16 | connection.set_schema_to_public() 17 | Product.objects.all().delete() 18 | get_user_model().objects.all().delete() 19 | get_tenant_model().objects.all().delete() 20 | get_tenant_domain_model().objects.all().delete() 21 | get_tenant_user_model().objects.all().delete() 22 | 23 | 24 | class TestProductTenant(BaseTest): 25 | def setUp(self) -> None: 26 | self.user = UserFactory() 27 | 28 | def test_can_create_product_public_schema(self): 29 | for i in range(3): 30 | ProductFactory() 31 | 32 | total_products = Product.objects.all() 33 | 34 | self.assertEqual(3, total_products.count()) 35 | 36 | def test_can_create_product_on_different_schemas(self): 37 | connection.set_schema_to_public() 38 | 39 | ProductFactory() 40 | 41 | test_schema = TenantFactory(schema_name="test_schema", tenant_name="Test") 42 | 43 | total_products = Product.objects.all() 44 | 45 | self.assertEqual(1, total_products.count()) 46 | 47 | test_schema.activate() 48 | 49 | ProductFactory() 50 | 51 | total_products = Product.objects.all() 52 | 53 | self.assertEqual(1, total_products.count()) 54 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from customers.tests.factories import TenantFactory, TenantUserFactory, UserFactory 2 | from django.contrib.auth import get_user_model 3 | from django.db import IntegrityError, connection 4 | from django.urls import reverse 5 | from django_tenants.test.cases import FastTenantTestCase 6 | from django_tenants.utils import get_tenant_domain_model, get_tenant_model 7 | from django_webtest import WebTest 8 | from dtu_test_app.models import Product 9 | 10 | from django_tenants_url.utils import get_tenant_user_model 11 | 12 | from .factories import ProductFactory 13 | 14 | 15 | class BaseTest(FastTenantTestCase): 16 | def tearDown(self) -> None: 17 | Product.objects.all().delete() 18 | connection.set_schema_to_public() 19 | Product.objects.all().delete() 20 | get_user_model().objects.all().delete() 21 | get_tenant_model().objects.all().delete() 22 | get_tenant_domain_model().objects.all().delete() 23 | get_tenant_user_model().objects.all().delete() 24 | 25 | 26 | class TestProductView(BaseTest, WebTest): 27 | def test_can_access_product_list(self): 28 | url = reverse("product-list") 29 | 30 | response = self.app.get(url) 31 | 32 | self.assertEqual(200, response.status_code) 33 | 34 | def test_can_access_tenant_data(self): 35 | user = UserFactory() 36 | tenant = TenantFactory() 37 | TenantUserFactory(user=user, tenant=tenant) 38 | 39 | tenant.activate() 40 | 41 | ProductFactory() 42 | 43 | url = reverse("product-tenant-list") 44 | 45 | response = self.app.get( 46 | url, user=user, headers={"X_REQUEST_ID": str(tenant.tenant_uuid)} 47 | ) 48 | 49 | self.assertEqual(response.status_code, 200) 50 | self.assertEqual(1, len(response.json)) 51 | 52 | def test_different_result_for_tenant_and_public(self): 53 | url = reverse("product-tenant-list") 54 | user = UserFactory() 55 | tenant = TenantFactory() 56 | TenantUserFactory(user=user, tenant=tenant) 57 | 58 | tenant.activate() 59 | 60 | for i in range(3): 61 | ProductFactory() 62 | 63 | response = self.app.get( 64 | url, user=user, headers={"X_REQUEST_ID": str(tenant.tenant_uuid)} 65 | ) 66 | 67 | self.assertEqual(response.status_code, 200) 68 | self.assertEqual(3, len(response.json)) 69 | 70 | tenant.deactivate() 71 | 72 | for i in range(10): 73 | ProductFactory() 74 | 75 | response = self.app.get(url, user=user) 76 | 77 | self.assertEqual(10, len(response.json)) 78 | 79 | def test_has_no_tenant_permission(self): 80 | url = reverse("product-tenant-list") 81 | user = UserFactory() 82 | tenant = TenantFactory() 83 | another_tenant = TenantFactory() 84 | 85 | TenantUserFactory(user=user, tenant=tenant) 86 | 87 | tenant.activate() 88 | 89 | for i in range(3): 90 | ProductFactory() 91 | 92 | response = self.app.get( 93 | url, user=user, headers={"X_REQUEST_ID": str(tenant.tenant_uuid)} 94 | ) 95 | 96 | self.assertEqual(response.status_code, 200) 97 | self.assertEqual(3, len(response.json)) 98 | 99 | another_tenant.activate() 100 | 101 | for i in range(12): 102 | ProductFactory() 103 | 104 | total = Product.objects.all() 105 | 106 | self.assertEqual(12, total.count()) 107 | 108 | response = self.app.get( 109 | url, 110 | user=user, 111 | headers={"X_REQUEST_ID": str(another_tenant.tenant_uuid)}, 112 | expect_errors=True, 113 | ) 114 | 115 | self.assertEqual(response.status_code, 403) 116 | -------------------------------------------------------------------------------- /dtu_test_project/dtu_test_app/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.generics import ListAPIView 2 | from rest_framework.views import APIView 3 | 4 | from django_tenants_url.permissions import IsTenantAllowedOrPublic 5 | 6 | from .models import Product 7 | from .serializers import ProductSerializer 8 | 9 | 10 | class ProductListAPIView(ListAPIView): 11 | serializer_class = ProductSerializer 12 | 13 | def get_queryset(self): 14 | return Product.objects.all() 15 | 16 | 17 | class ProductTenantListAPIView(ListAPIView): 18 | serializer_class = ProductSerializer 19 | permission_classes = [IsTenantAllowedOrPublic] 20 | 21 | def get_queryset(self): 22 | return Product.objects.all() 23 | -------------------------------------------------------------------------------- /dtu_test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dtu_test.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /dtu_test_project/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 6.0 3 | addopts = --create-db --pspec 4 | DJANGO_SETTINGS_MODULE = dtu_test.testing.settings 5 | python_files = tests.py test_*.py *_tests.py 6 | norecursedirs = .*, CVS, _darcs, {arch}, *.egg, django, south -------------------------------------------------------------------------------- /dtu_test_project/requirements.txt: -------------------------------------------------------------------------------- 1 | bleach 2 | django==3.2.13 3 | djangorestframework==3.13.1 4 | django-tenants==3.4.2 5 | postgres 6 | 7 | # TEST 8 | django_extensions 9 | django-webtest 10 | factory_boy 11 | pytest-django 12 | pytest-pspec 13 | unittest-data-provider==1.0.1 14 | webtest 15 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django Tenants URL 2 | site_description: The little library for your django projects with little effort. 3 | 4 | repo_name: tarsil/django-tenants-url 5 | repo_url: https://github.com/tarsil/django-tenants-url 6 | edit_uri: "" 7 | 8 | theme: 9 | name: material 10 | 11 | palette: 12 | # Toggle light mode 13 | - scheme: default 14 | primary: green 15 | accent: red 16 | toggle: 17 | icon: material/toggle-switch 18 | name: Switch to light mode 19 | 20 | # Toggle dark mode 21 | - scheme: slate 22 | primary: pink 23 | accent: blue 24 | toggle: 25 | icon: material/toggle-switch-off-outline 26 | name: Switch to dark mode 27 | 28 | markdown_extensions: 29 | - toc: 30 | permalink: true 31 | - pymdownx.highlight 32 | - pymdownx.superfences 33 | - pymdownx.emoji: 34 | emoji_index: !!python/name:materialx.emoji.twemoji 35 | emoji_generator: !!python/name:materialx.emoji.to_svg 36 | 37 | nav: 38 | - Installation: "index.md" 39 | - Models: "models.md" 40 | - Views: "views.md" 41 | - Serializers: "serializers.md" 42 | - Permissions: "permissions.md" 43 | - URLs: "urls.md" 44 | - Middleware: "middleware.md" 45 | - Handlers: "handlers.md" 46 | - Utils: "utils.md" 47 | - Settings: "settings.md" 48 | - Others: 49 | - Example: "example.md" 50 | - Releases: "releases.md" 51 | # - Utils: 52 | # - Audit: "utils/audit.md" 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 119 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | twine 2 | build 3 | django>=3.2.13 4 | djangorestframework==3.13.1 5 | django-tenants==3.4.2 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import re 4 | import shutil 5 | import sys 6 | from io import open 7 | 8 | from setuptools import find_packages, setup 9 | 10 | CURRENT_PYTHON = sys.version_info[:2] 11 | REQUIRED_PYTHON = (3, 7) 12 | 13 | # This check and everything above must remain compatible with Python 2.7. 14 | if CURRENT_PYTHON < REQUIRED_PYTHON: 15 | sys.stderr.write( 16 | """ 17 | ========================== 18 | Unsupported Python version 19 | ========================== 20 | This version of Django Tenants URL requires Python {}.{}, but you're trying 21 | to install it on Python {}.{}. 22 | This may be because you are using a version of pip that doesn't 23 | understand the python_requires classifier. Make sure you 24 | have pip >= 9.0 and setuptools >= 24.2, then try again: 25 | $ python -m pip install --upgrade pip setuptools 26 | $ python -m pip install django_tenants_url 27 | This will install the latest version of Django Tenants URL which works on 28 | your version of Python. If you can't upgrade your pip (or Python), request 29 | an older version of Django Tenants URL. 30 | """.format( 31 | *(REQUIRED_PYTHON + CURRENT_PYTHON) 32 | ) 33 | ) 34 | sys.exit(1) 35 | 36 | 37 | def read(f): 38 | return open(f, "r", encoding="utf-8").read() 39 | 40 | 41 | def get_version(package): 42 | """ 43 | Return package version as listed in `__version__` in `init.py`. 44 | """ 45 | init_py = open(os.path.join(package, "__init__.py")).read() 46 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 47 | 48 | 49 | version = get_version("django_tenants_url") 50 | 51 | setup( 52 | name="django_tenants_url", 53 | version=version, 54 | url="https://django-tenants-url.tarsild.io/", 55 | license="MIT", 56 | description="Django Tenants managed by a single URL.", 57 | long_description=read("README.md"), 58 | long_description_content_type="text/markdown", 59 | author="Tiago Silva", 60 | author_email="tiago.arasilva@gmail.com", 61 | packages=find_packages(exclude=["tests*", "dtu_test_project*"]), 62 | include_package_data=True, 63 | install_requires=[ 64 | "bleach>=4.1.0", 65 | "django>=2.2", 66 | "pytz", 67 | "djangorestframework>=3.13.1", 68 | "django-tenants>=3.4.3", 69 | ], 70 | python_requires=">=3.7", 71 | zip_safe=False, 72 | classifiers=[ 73 | "Development Status :: 5 - Production/Stable", 74 | "Environment :: Web Environment", 75 | "Framework :: Django", 76 | "Framework :: Django :: 2.2", 77 | "Framework :: Django :: 3.0", 78 | "Framework :: Django :: 3.1", 79 | "Framework :: Django :: 3.2", 80 | "Framework :: Django :: 4.0", 81 | "Framework :: Django :: 4.1", 82 | "Intended Audience :: Developers", 83 | "License :: OSI Approved :: BSD License", 84 | "Operating System :: OS Independent", 85 | "Programming Language :: Python", 86 | "Programming Language :: Python :: 3", 87 | "Programming Language :: Python :: 3.6", 88 | "Programming Language :: Python :: 3.7", 89 | "Programming Language :: Python :: 3.8", 90 | "Programming Language :: Python :: 3.9", 91 | "Programming Language :: Python :: 3.10", 92 | "Programming Language :: Python :: 3 :: Only", 93 | "Topic :: Internet :: WWW/HTTP", 94 | ], 95 | ) 96 | -------------------------------------------------------------------------------- /unittest.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | db: 4 | image: postgres:12.3 5 | environment: 6 | POSTGRES_HOST_AUTH_METHOD: trust 7 | POSTGRES_DB: dtu_test_project 8 | POSTGRES_PASSWORD: root 9 | POSTGRES_USER: postgres 10 | expose: 11 | - "5432" 12 | ports: 13 | - "5432:5432" 14 | --------------------------------------------------------------------------------