├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── pypi.yml ├── .gitignore ├── CHANGES.rst ├── LICENSE ├── README.md ├── run-qa-checks ├── setup.cfg ├── setup.py ├── swapper └── __init__.py ├── tests ├── __init__.py ├── alt_app │ ├── __init__.py │ └── models.py ├── default_app │ ├── __init__.py │ └── models.py ├── settings.py ├── swap_settings.py └── test_swapper.py └── tox.ini /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Open a bug report 4 | title: "[bug] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of the bug or unexpected behavior. 12 | 13 | **Steps To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System Informatioon:** 27 | - OS: [e.g. Ubuntu 24.04 LTS] 28 | - Python Version: [e.g. Python 3.11.2] 29 | - Django Version: [e.g. Django 4.2.5] 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[feature] " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Please use the Discussion Forum to ask questions 4 | title: "[question] " 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please use the [Discussion Forum](https://github.com/openwisp/django-swappable-models/discussions) to ask questions. 11 | 12 | We will take care of moving the discussion to a more relevant repository if needed. 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | commit-message: 13 | prefix: "[deps] " 14 | - package-ecosystem: "github-actions" # Check for GitHub Actions updates 15 | directory: "/" # The root directory where the Ansible role is located 16 | schedule: 17 | interval: "monthly" # Check for updates weekly 18 | commit-message: 19 | prefix: "[ci] " 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Checklist 2 | 3 | - [ ] I have read the [OpenWISP Contributing Guidelines](http://openwisp.io/docs/developer/contributing.html). 4 | - [ ] I have manually tested the changes proposed in this pull request. 5 | - [ ] I have written new test cases for new code and/or updated existing tests for changes to existing code. 6 | - [ ] I have updated the documentation. 7 | 8 | ## Reference to Existing Issue 9 | 10 | Closes #. 11 | 12 | Please [open a new issue](https://github.com/openwisp/django-swappable-models/issues/new/choose) if there isn't an existing issue yet. 13 | 14 | ## Description of Changes 15 | 16 | Please describe these changes. 17 | 18 | ## Screenshot 19 | 20 | Please include any relevant screenshots. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Django Swappable Models Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Python==${{ matrix.python-version }} 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: 19 | - "3.9" 20 | - "3.10" 21 | - "3.11" 22 | - "3.12" 23 | - "3.13" 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | ref: ${{ github.event.pull_request.head.sha }} 29 | 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - name: Install dependencies 36 | id: deps 37 | run: | 38 | pip install -U pip wheel setuptools 39 | pip install tox tox-gh-actions 40 | pip install openwisp-utils[qa]@https://github.com/openwisp/openwisp-utils/tarball/master 41 | 42 | - name: QA checks 43 | run: ./run-qa-checks 44 | 45 | - name: Test 46 | if: ${{ !cancelled() && steps.deps.conclusion == 'success' }} 47 | run: tox 48 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package to Pypi.org 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | id-token: write 9 | 10 | jobs: 11 | pypi-publish: 12 | name: Release Python Package on Pypi.org 13 | runs-on: ubuntu-latest 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/p/swapper 17 | permissions: 18 | id-token: write 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: '3.10' 25 | - name: Install dependencies 26 | run: | 27 | pip install -U pip 28 | pip install build 29 | - name: Build package 30 | run: python -m build 31 | - name: Publish package distributions to PyPI 32 | uses: pypa/gh-action-pypi-publish@v1.12.4 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | *.egg 4 | *.sw? 5 | build 6 | dist 7 | README.rst 8 | /.eggs/ 9 | /.tox/ 10 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 1.4.0 [2024-08-14] 5 | -------------------------- 6 | 7 | Changes 8 | ~~~~~~~ 9 | 10 | Dependencies 11 | ++++++++++++ 12 | 13 | - Added support for Django ``4.2`` and ``5.0``. 14 | - Dropped support for Django ``Django 4.0a1``. 15 | - Added support for Python ``3.10`` and ``3.11``. 16 | - Dropped support for Python ``3.7``. 17 | 18 | Verson 1.3.0 [2021-11-29] 19 | ------------------------- 20 | 21 | - [change] Allow possibility to point swappable dependency to specific 22 | migration number (instead of only to ``__latest__``) 23 | 24 | Version 1.2.0 [2021-11-12] 25 | -------------------------- 26 | 27 | - [feature] Add possibility to point swappable dependency to 28 | ``__latest__`` 29 | - [change] Added support for Python 3.9 30 | - [change] Added support for Django 3.2 and Django 4.0a1 31 | - [change] Dropped support for old Django versions (<2.2) 32 | - [change] Dropped support for old Python versions (<3.7) 33 | - [feature] Added optional ``require_ready`` argument to ``load_model`` 34 | function 35 | 36 | Version 1.1.2 [2020-01-15] 37 | -------------------------- 38 | 39 | - [deps] Verified support for python 3.8 40 | - [deps] Added support for Django 3.0 and Django Rest Framework 3.11 41 | 42 | Version 1.1.1 [2019-07-23] 43 | -------------------------- 44 | 45 | - [deps] Drop python<3.3 support 46 | - [deps] Added support for python 3.7 47 | - [deps] Django 2 support added 48 | 49 | Version 1.1.0 [2017-05-11] 50 | -------------------------- 51 | 52 | - [test] Added tests for swapper.split 53 | - `#13 `_ 54 | [fix] Handle contrib apps and apps with dot in app_label. 55 | 56 | Version 1.0.0 [2016-08-26] 57 | -------------------------- 58 | 59 | - [docs] Improved usuability docs 60 | - `86e238 61 | `_: 62 | [deps] Compatibility with django 1.10 added 63 | 64 | Version 0.3.0 [2015-11-17] 65 | -------------------------- 66 | 67 | - `#9 `_ 68 | [deps] Added support for django 1.9 69 | 70 | Version 0.2.2 [2015-06-16] 71 | -------------------------- 72 | 73 | - [deps] Added support for django~=1.6.0 74 | - [deps] Added support for Python 3.3 75 | - [docs] Fix model reference in README 76 | - [docs] Notes for load_model initialization (`for more info see #2 77 | `_) 78 | 79 | Version 0.2.1 [2014-11-18] 80 | -------------------------- 81 | 82 | - [docs] Added examples for migration scripts 83 | - [docs] Documented use of Functions 84 | - [fix] Fixed Lookup Error in load_model 85 | 86 | Version 0.2.0 [2014-09-13] 87 | -------------------------- 88 | 89 | - [deps] Added support for Django 1.7 90 | - [feature] Added `swapper.dependency` function. 91 | - [tests] Added tests 92 | 93 | Version 0.1.1 [2014-01-09] 94 | -------------------------- 95 | 96 | - [docs] Added References 97 | 98 | Version 0.1.0 [2014-01-09] 99 | -------------------------- 100 | 101 | - Added base functions for swapping models 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, OpenWISP 2 | Copyright (c) 2014-2017, S. Andrew Sheppard, http://wq.io/ 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Swapper 2 | ======= 3 | 4 | [![Dependency monitoring](https://img.shields.io/librariesio/release/github/openwisp/django-swappable-models)](https://libraries.io/github/openwisp/django-swappable-models) 5 | 6 | #### Django Swappable Models - No longer only for auth.User! 7 | 8 | Swapper is an unofficial API for the [undocumented] but very powerful Django 9 | feature: swappable models. Swapper facilitates implementing 10 | arbitrary swappable models in your own reusable apps. 11 | 12 | [![Latest PyPI Release](https://img.shields.io/pypi/v/swapper.svg)](https://pypi.org/project/swapper) 13 | [![Release Notes](https://img.shields.io/github/release/openwisp/django-swappable-models.svg 14 | )](https://github.com/wq/django-swappable-models/releases) 15 | [![License](https://img.shields.io/pypi/l/swapper.svg)](https://github.com/openwisp/django-swappable-models/blob/master/LICENSE) 16 | [![GitHub Stars](https://img.shields.io/github/stars/openwisp/django-swappable-models.svg)](https://github.com/openwisp/django-swappable-models/stargazers) 17 | [![GitHub Forks](https://img.shields.io/github/forks/openwisp/django-swappable-models.svg)](https://github.com/openwisp/django-swappable-models/network) 18 | [![GitHub Issues](https://img.shields.io/github/issues/openwisp/django-swappable-models.svg)](https://github.com/openwisp/django-swappable-models/issues) 19 | 20 | [![Build Status](https://github.com/openwisp/django-swappable-models/actions/workflows/ci.yml/badge.svg)](https://github.com/openwisp/django-swappable-models/actions/workflows/ci.yml) 21 | [![Python Support](https://img.shields.io/pypi/pyversions/swapper.svg)](https://pypi.org/project/swapper) 22 | [![Django Support](https://img.shields.io/pypi/djversions/swapper.svg)](https://pypi.org/project/swapper) 23 | 24 | ## Motivation 25 | 26 | Suppose your reusable app has two related tables: 27 | 28 | ```python 29 | from django.db import models 30 | class Parent(models.Model): 31 | name = models.TextField() 32 | 33 | class Child(models.Model): 34 | name = models.TextField() 35 | parent = models.ForeignKey(Parent) 36 | ``` 37 | 38 | Suppose further that you want to allow the user to subclass either or both of 39 | these models and supplement them with their own additional fields. You could use 40 | Abstract classes (e.g. `BaseParent` and `BaseChild`) for this, but then you 41 | would either need to: 42 | 43 | 1. Avoid putting the foreign key on `BaseChild` and tell the user they need to 44 | do it. 45 | 2. Put the foreign key on `BaseChild`, but make `Parent` a concrete model that 46 | can't be swapped 47 | 3. Use swappable models, together with `ForeignKeys` that read the swappable 48 | settings. 49 | 50 | This third approach is taken by Django to facilitate [swapping the auth.User model]. The `auth.User` swappable code was implemented in a generic way that allows it to be used for any model. Although this capability is currently [undocumented] while any remaining issues are being sorted out, it has proven to be very stable and useful in our experience. 51 | 52 | Swapper is essentially a simple API wrapper around this existing functionality. Note that Swapper is primarily a tool for library authors; users of your reusable app generally should not need to know about Swapper in order to use it. (See the notes on [End User Documentation](#end-user-documentation) below.) 53 | 54 | ### Real-World Examples 55 | 56 | Swapper is used extensively in several OpenWISP packages to facilitate customization and extension. Notable examples include: 57 | 58 | * [openwisp-users] 59 | * [openwisp-controller] 60 | * [openwisp-radius] 61 | 62 | The use of swapper in these packages promotes [Software Reusability][reusability], one of the core values of the OpenWISP project. 63 | 64 | ## Creating a Reusable App 65 | 66 | First, make sure you have `swapper` installed. If you are publishing your reusable app as a Python package, be sure to add `swapper` to your project's dependencies (e.g. `setup.py`) to ensure that users of your app don't have errors integrating it. 67 | 68 | ```bash 69 | pip3 install swapper 70 | ``` 71 | Extending the above example, you might create two abstract base classes and corresponding default implementations: 72 | 73 | ```python 74 | # reusableapp/models.py 75 | from django.db import models 76 | import swapper 77 | 78 | class BaseParent(models.Model): 79 | # minimal base implementation ... 80 | class Meta: 81 | abstract = True 82 | 83 | class Parent(BaseParent): 84 | # default (swappable) implementation ... 85 | class Meta: 86 | swappable = swapper.swappable_setting('reusableapp', 'Parent') 87 | 88 | class BaseChild(models.Model): 89 | parent = models.ForeignKey(swapper.get_model_name('reusableapp', 'Parent')) 90 | # minimal base implementation ... 91 | class Meta: 92 | abstract = True 93 | 94 | class Child(BaseChild): 95 | # default (swappable) implementation ... 96 | class Meta: 97 | swappable = swapper.swappable_setting('reusableapp', 'Child') 98 | ``` 99 | 100 | ### Loading Swapped Models 101 | 102 | In your reusable views and other functions, always use the swapper instead of importing swappable models directly. This is because you might not know whether the user of your app is using your default implementation or their own version. 103 | 104 | ```python 105 | # reusableapp/views.py 106 | 107 | # Might work, might not 108 | # from .models import Parent 109 | 110 | import swapper 111 | Parent = swapper.load_model("reusableapp", "Parent") 112 | Child = swapper.load_model("reusableapp", "Child") 113 | 114 | def view(request, *args, **kwargs): 115 | qs = Parent.objects.all() 116 | # ... 117 | ``` 118 | 119 | > Note: `swapper.load_model()` is the general equivalent of [get_user_model()] and subject to the same constraints: e.g. it should not be used until after the model system has fully initialized. 120 | 121 | ### Migration Scripts 122 | Swapper can also be used in migration scripts to facilitate dependency ordering and foreign key references. To use this feature in your library, generate a migration script with `makemigrations` and make the following changes. In general, users of your library should not need to make any similar changes to their own migration scripts. The one exception is if you have multiple levels of swappable models with foreign keys pointing to each other. 123 | 124 | ```diff 125 | # reusableapp/migrations/0001_initial.py 126 | 127 | from django.db import models, migrations 128 | < from django.conf import settings 129 | > import swapper 130 | 131 | class Migration(migrations.Migration): 132 | 133 | dependencies = [ 134 | < migrations.swappable_dependency(settings.REUSABLEAPP_PARENT_MODEL), 135 | > swapper.dependency('reusableapp', 'Parent') 136 | ] 137 | 138 | operations = [ 139 | migrations.CreateModel( 140 | name='Child', 141 | fields=[ 142 | ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), 143 | ], 144 | options={ 145 | < 'swappable': 'REUSABLEAPP_CHILD_MODEL', 146 | > 'swappable': swapper.swappable_setting('reusableapp', 'Child'), 147 | }, 148 | bases=(models.Model,), 149 | ), 150 | migrations.CreateModel( 151 | name='Parent', 152 | fields=[ 153 | ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), 154 | ], 155 | options={ 156 | < 'swappable': 'REUSABLEAPP_PARENT_MODEL', 157 | > 'swappable': swapper.swappable_setting('reusableapp', 'Parent'), 158 | }, 159 | bases=(models.Model,), 160 | ), 161 | migrations.AddField( 162 | model_name='child', 163 | name='parent', 164 | < field=models.ForeignKey(to=settings.REUSABLEAPP_PARENT_MODEL), 165 | > field=models.ForeignKey(to=swapper.get_model_name('reusableapp', 'Parent')), 166 | preserve_default=True, 167 | ), 168 | ] 169 | ``` 170 | 171 | ## End User Documentation 172 | With the above setup, the user of your app can override one or both models in their own app. You might provide them with an example like this: 173 | 174 | ```python 175 | # myapp/models.py 176 | from reusableapp.models import BaseParent 177 | class Parent(BaseParent): 178 | # custom implementation ... 179 | ``` 180 | 181 | Then, tell your users to update their settings to trigger the swap. 182 | 183 | ```python 184 | # myproject/settings.py 185 | REUSABLEAPP_PARENT_MODEL = "myapp.Parent" 186 | ``` 187 | 188 | The goal is to make this process just as easy for your end user as [swapping the auth.User model] is. As with `auth.User`, there are some important caveats that you may want to inform your users about. 189 | 190 | The biggest issue is that your users will probably need to define the swapped model settings **before creating any migrations** for their implementation of `myapp`. Due to key assumptions made within Django's migration infrastructure, it is difficult to start out with a default (non-swapped) model and then later to switch to a swapped implementation without doing some migration hacking. This is somewhat awkward - as your users will most likely want to try out your default implementation before deciding to customize it. Unfortunately, there isn't an easy workaround due to how the swappable setting is currently implemented in Django core. This will likely be addressed in future Django versions (see [#10] and [Django ticket #25313]). 191 | 192 | ## API Documentation 193 | 194 | Here is the full API for `swapper`, which you may find useful in creating your reusable app code. End users of your library should generally not need to reference this API. 195 | 196 | function | purpose 197 | ---------|-------- 198 | `swappable_setting(app_label, model)` | Generates a swappable setting name for the provided model (e.g. `"REUSABLEAPP_PARENT_MODEL"`) 199 | `is_swapped(app_label, model)` | Determines whether or not a given model has been swapped. (Returns the model name if swapped, otherwise `False`) 200 | `get_model_name(app_label, model)` | Gets the name of the model the swappable model has been swapped for (or the name of the original model if not swapped.) 201 | `get_model_names(app_label, models)` | Match a list of model names to their swapped versions. All of the models should be from the same app (though their swapped versions need not be). 202 | `load_model(app_label, model, required=True)` | Load the swapped model class for a swappable model (or the original model if it hasn't been swapped). If your code can function without the specified model, set `required = False`. 203 | `dependency(app_label, model, version=None)` | Generate a dependency tuple for use in migrations. Use `version` only when depending on the first migration of the target dependency doesn't work (eg: when a specific migration needs to be depended upon), we recommend avoid using `version='__latest__'` because it can have serious [drawbacks] when new migrations are added to the module which is being depended upon. 204 | `set_app_prefix(app_label, prefix)` | Set a custom prefix for swappable settings (the default is the upper case `app_label`). This can be useful if the app has a long name or is part of a larger framework. This should be set at the top of your models.py. 205 | `join(app_label, model)`, `split(model)` | Utilities for splitting and joining `"app.Model"` strings and `("app", "Model")` tuples. 206 | 207 | [undocumented]: https://code.djangoproject.com/ticket/19103 208 | [swapping the auth.User model]: https://docs.djangoproject.com/en/4.0/topics/auth/customizing/#auth-custom-user 209 | [openwisp-users]: https://github.com/openwisp/openwisp-users#extend-openwisp-users 210 | [openwisp-controller]: https://github.com/openwisp/openwisp-controller#extending-openwisp-controller 211 | [openwisp-radius]: https://openwisp-radius.readthedocs.io/en/latest/developer/how_to_extend.html 212 | [reusability]: https://openwisp.io/docs/general/values.html#software-reusability-means-long-term-sustainability 213 | [get_user_model()]: https://docs.djangoproject.com/en/4.0/topics/auth/customizing/#referencing-the-user-model 214 | [#10]: https://github.com/openwisp/django-swappable-models/issues/10 215 | [Django ticket #25313]: https://code.djangoproject.com/ticket/25313 216 | [drawbacks]: https://code.djangoproject.com/ticket/23071 217 | -------------------------------------------------------------------------------- /run-qa-checks: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | openwisp-qa-check \ 4 | --skip-checkmigrations 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | max-line-length = 110 6 | # W503: line break before or after operator 7 | # W504: line break after or after operator 8 | # W605: invalid escape sequence 9 | ignore = W605, W503, W504 10 | 11 | [isort] 12 | multi_line_output=3 13 | use_parentheses=True 14 | include_trailing_comma=True 15 | force_grid_wrap=0 16 | line_length=88 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | LONG_DESCRIPTION = """ 4 | The unofficial Django swappable models API. 5 | """ 6 | 7 | 8 | def readme(): 9 | try: 10 | with open('README.md', encoding='utf-8') as f: 11 | return f.read() 12 | except IOError: 13 | return LONG_DESCRIPTION 14 | 15 | 16 | setup( 17 | name='swapper', 18 | use_scm_version=True, 19 | setup_requires=['setuptools_scm'], 20 | author='S. Andrew Sheppard', 21 | author_email='andrew@wq.io', 22 | url='https://github.com/openwisp/django-swappable-models', 23 | license='MIT', 24 | packages=['swapper'], 25 | description=LONG_DESCRIPTION.strip(), 26 | long_description=readme(), 27 | long_description_content_type="text/markdown", 28 | classifiers=[ 29 | 'Development Status :: 5 - Production/Stable', 30 | 'Environment :: Web Environment', 31 | 'License :: OSI Approved :: MIT License', 32 | 'Natural Language :: English', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python :: 3.9', 35 | 'Programming Language :: Python :: 3.10', 36 | 'Programming Language :: Python :: 3.11', 37 | 'Programming Language :: Python :: 3.12', 38 | 'Programming Language :: Python :: 3.13', 39 | 'Framework :: Django', 40 | 'Framework :: Django :: 4.2', 41 | 'Framework :: Django :: 5.0', 42 | 'Framework :: Django :: 5.1', 43 | 'Framework :: Django :: 5.2', 44 | ], 45 | tests_require=['django>=4.2'], 46 | python_requires='>=3.9', 47 | ) 48 | -------------------------------------------------------------------------------- /swapper/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db.migrations import swappable_dependency 5 | 6 | _prefixes = {} 7 | 8 | 9 | def swappable_setting(app_label, model): 10 | """Returns the setting name to use for the given model 11 | 12 | Returns the setting name to use for the given model (i.e. 13 | AUTH_USER_MODEL) 14 | """ 15 | prefix = _prefixes.get(app_label, app_label) 16 | setting = "{prefix}_{model}_MODEL".format( 17 | prefix=prefix.upper(), model=model.upper() 18 | ) 19 | 20 | # Ensure this attribute exists to avoid migration issues in Django 1.7 21 | if not hasattr(settings, setting): 22 | setattr(settings, setting, join(app_label, model)) 23 | 24 | return setting 25 | 26 | 27 | def is_swapped(app_label, model): 28 | """Returns the value of the swapped setting. 29 | 30 | Returns the value of the swapped setting, or False if the model hasn't 31 | been swapped. 32 | """ 33 | default_model = join(app_label, model) 34 | setting = swappable_setting(app_label, model) 35 | value = getattr(settings, setting, default_model) 36 | if value != default_model: 37 | return value 38 | else: 39 | return False 40 | 41 | 42 | def get_model_name(app_label, model): 43 | """Returns [app_label.model]. 44 | 45 | Returns [app_label.model] unless the model has been swapped, in which 46 | case returns the swappable setting value. 47 | """ 48 | return is_swapped(app_label, model) or join(app_label, model) 49 | 50 | 51 | def dependency(app_label, model, version=None): 52 | """Returns a Django 1.7+ style dependency tuple 53 | 54 | Returns a Django 1.7+ style dependency tuple for inclusion in 55 | migration.dependencies[] 56 | """ 57 | dependencies = swappable_dependency(get_model_name(app_label, model)) 58 | if not version: 59 | return dependencies 60 | return dependencies[0], version 61 | 62 | 63 | def get_model_names(app_label, models): 64 | """Map model names to their swapped equivalents for the given app""" 65 | return dict((model, get_model_name(app_label, model)) for model in models) 66 | 67 | 68 | def load_model(app_label, model, required=True, require_ready=True): 69 | """Load the specified model class, or the class it was swapped out for.""" 70 | swapped = is_swapped(app_label, model) 71 | if swapped: 72 | app_label, model = split(swapped) 73 | 74 | try: 75 | cls = apps.get_model(app_label, model, require_ready=require_ready) 76 | except LookupError: 77 | cls = None 78 | 79 | if cls is None and required: 80 | raise ImproperlyConfigured( 81 | "Could not find {name}!".format(name=join(app_label, model)) 82 | ) 83 | return cls 84 | 85 | 86 | def set_app_prefix(app_label, prefix): 87 | """Set a custom prefix to use for the given app (e.g. WQ)""" 88 | _prefixes[app_label] = prefix 89 | 90 | 91 | def join(app_label, model): 92 | return "{app_label}.{model}".format( 93 | app_label=app_label, 94 | model=model, 95 | ) 96 | 97 | 98 | def split(model): 99 | app_label, _, model = model.rpartition(".") 100 | return app_label, model 101 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | from django.core.management import call_command 5 | from django.test.utils import setup_test_environment 6 | 7 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', "tests.settings") 8 | setup_test_environment() 9 | 10 | django.setup() 11 | call_command('makemigrations', 'default_app', interactive=False) 12 | if os.environ["DJANGO_SETTINGS_MODULE"] == "tests.swap_settings": 13 | call_command('makemigrations', 'alt_app', interactive=False) 14 | call_command('migrate', interactive=False) 15 | -------------------------------------------------------------------------------- /tests/alt_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/django-swappable-models/7b38f9c4d8e54ad39d1a8689091bc0f9857bd31f/tests/alt_app/__init__.py -------------------------------------------------------------------------------- /tests/alt_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from tests.default_app.models import BaseType 4 | 5 | 6 | class Type(BaseType): 7 | code = models.SlugField() 8 | 9 | class Meta: 10 | app_label = 'alt_app' 11 | -------------------------------------------------------------------------------- /tests/default_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwisp/django-swappable-models/7b38f9c4d8e54ad39d1a8689091bc0f9857bd31f/tests/default_app/__init__.py -------------------------------------------------------------------------------- /tests/default_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | import swapper 4 | 5 | 6 | class BaseType(models.Model): 7 | name = models.CharField(max_length=255) 8 | 9 | class Meta: 10 | abstract = True 11 | 12 | 13 | class Type(BaseType): 14 | class Meta: 15 | swappable = swapper.swappable_setting("default_app", "Type") 16 | 17 | 18 | class Item(models.Model): 19 | type = models.ForeignKey( 20 | swapper.get_model_name('default_app', "Type"), on_delete=models.CASCADE 21 | ) 22 | name = models.CharField(max_length=255) 23 | description = models.TextField() 24 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = '1234' 2 | INSTALLED_APPS = ('tests.default_app',) 3 | MIDDLEWARE_CLASSES = tuple() 4 | DATABASES = { 5 | 'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}, 6 | } 7 | SWAP = False 8 | -------------------------------------------------------------------------------- /tests/swap_settings.py: -------------------------------------------------------------------------------- 1 | from . import settings 2 | 3 | DEFAULT_APP_TYPE_MODEL = "alt_app.Type" 4 | MIDDLEWARE_CLASSES = settings.MIDDLEWARE_CLASSES 5 | DATABASES = settings.DATABASES 6 | INSTALLED_APPS = settings.INSTALLED_APPS 7 | INSTALLED_APPS += ('tests.alt_app',) 8 | SECRET_KEY = settings.SECRET_KEY 9 | SWAP = True 10 | -------------------------------------------------------------------------------- /tests/test_swapper.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.test import TestCase 6 | 7 | import swapper 8 | 9 | try: 10 | from django.db import migrations # noqa 11 | except ImportError: 12 | DJ17 = False 13 | else: 14 | DJ17 = True 15 | 16 | 17 | class SwapperTestCase(TestCase): 18 | # Tests that should work whether or not default_app.Type is swapped 19 | def test_fields(self): 20 | Type = swapper.load_model('default_app', 'Type') 21 | fields = dict((field.name, field) for field in Type._meta.fields) 22 | self.assertIn('name', fields) 23 | 24 | def test_create(self): 25 | Type = swapper.load_model('default_app', 'Type') 26 | Item = swapper.load_model('default_app', 'Item') 27 | 28 | Item.objects.create( 29 | type=Type.objects.create(name="Type 1"), 30 | name="Item 1", 31 | ) 32 | 33 | self.assertEqual(Item.objects.count(), 1) 34 | 35 | item = Item.objects.all()[0] 36 | self.assertEqual(item.type.name, "Type 1") 37 | 38 | def test_not_installed(self): 39 | Invalid = swapper.load_model("invalid_app", "Invalid", required=False) 40 | self.assertIsNone(Invalid) 41 | with self.assertRaises(ImproperlyConfigured): 42 | swapper.load_model("invalid_app", "Invalid", required=True) 43 | 44 | def test_non_contrib_app_split(self): 45 | self.assertEqual(swapper.split('alt_app.Type'), ('alt_app', 'Type')) 46 | 47 | def test_contrib_app_split(self): 48 | self.assertEqual( 49 | swapper.split('alt_app.contrib.named_things.NamedThing'), 50 | ('alt_app.contrib.named_things', 'NamedThing'), 51 | ) 52 | 53 | # Tests that only work if default_app.Type is swapped 54 | @unittest.skipUnless(settings.SWAP, "requires swapped models") 55 | def test_swap_setting(self): 56 | self.assertTrue(swapper.is_swapped("default_app", "Type")) 57 | self.assertEqual(swapper.get_model_name("default_app", "Type"), "alt_app.Type") 58 | 59 | @unittest.skipUnless(settings.SWAP, "requires swapped models") 60 | def test_swap_fields(self): 61 | Type = swapper.load_model('default_app', 'Type') 62 | fields = dict((field.name, field) for field in Type._meta.fields) 63 | self.assertIn('code', fields) 64 | 65 | @unittest.skipUnless(settings.SWAP, "requires swapped models") 66 | def test_swap_create(self): 67 | Type = swapper.load_model('default_app', 'Type') 68 | Item = swapper.load_model('default_app', 'Item') 69 | 70 | Item.objects.create( 71 | type=Type.objects.create( 72 | name="Type 1", 73 | code="type-1", 74 | ), 75 | name="Item 1", 76 | ) 77 | 78 | self.assertEqual(Item.objects.count(), 1) 79 | item = Item.objects.all()[0] 80 | self.assertEqual(item.type.code, "type-1") 81 | 82 | @unittest.skipUnless(settings.SWAP and DJ17, "requires swapped models & Django 1.7") 83 | def test_swap_dependency(self): 84 | self.assertEqual( 85 | swapper.dependency("default_app", "Type"), ("alt_app", "__first__") 86 | ) 87 | self.assertEqual( 88 | swapper.dependency("default_app", "Type", "__latest__"), 89 | ("alt_app", "__latest__"), 90 | ) 91 | self.assertEqual( 92 | swapper.dependency("default_app", "Type", "0001_custom_migration"), 93 | ("alt_app", "0001_custom_migration"), 94 | ) 95 | 96 | # Tests that only work if default_app.Type is *not* swapped 97 | @unittest.skipIf(settings.SWAP, "requires non-swapped models") 98 | def test_default_setting(self): 99 | self.assertFalse(swapper.is_swapped("default_app", "Type")) 100 | self.assertEqual( 101 | swapper.get_model_name("default_app", "Type"), "default_app.Type" 102 | ) 103 | 104 | @unittest.skipUnless( 105 | not settings.SWAP and DJ17, "requires non-swapped models & Django 1.7" 106 | ) 107 | def test_default_dependency(self): 108 | self.assertEqual( 109 | swapper.dependency("default_app", "Type"), 110 | ("default_app", "__first__"), 111 | ) 112 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39,310,311,312}-django42-{noswap, swap} 4 | py{310,311,312}-django50-{noswap, swap} 5 | py{310,311,312,313}-django51-{noswap, swap} 6 | py{310,311,312,313}-django52-{noswap, swap} 7 | lint 8 | 9 | [gh-actions] 10 | python = 11 | 3.9: py39, lint 12 | 3.10: py310, lint 13 | 3.11: py311, lint 14 | 3.12: py312, lint 15 | 3.13: py313, lint 16 | 17 | [testenv] 18 | commands = 19 | rm -rf tests/default_app/migrations/ tests/alt_app/migrations/ 20 | python -m unittest discover -s tests 21 | deps = 22 | django42: django~=4.2.0 23 | django50: django~=5.0.0 24 | django51: django~=5.1.0 25 | django52: django~=5.2.0rc 26 | setenv = 27 | noswap: DJANGO_SETTINGS_MODULE=tests.settings 28 | swap: DJANGO_SETTINGS_MODULE=tests.swap_settings 29 | allowlist_externals = rm 30 | 31 | [testenv:lint] 32 | commands = 33 | rm -rf tests/default_app/migrations/ tests/alt_app/migrations/ 34 | flake8 swapper tests 35 | allowlist_externals = 36 | rm 37 | flake8 38 | --------------------------------------------------------------------------------