pax_global_header00006660000000000000000000000064152456441150014521gustar00rootroot0000000000000052 comment=331b21b87d76c5298556d6c5095724761c303cf0 openwisp-django-swappable-models-5f67b69/000077500000000000000000000000001524564411500204345ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/.github/000077500000000000000000000000001524564411500217745ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/.github/FUNDING.yml000066400000000000000000000014641524564411500236160ustar00rootroot00000000000000# These are supported funding model platforms github: [openwisp] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: ["https://openwisp.org/sponsorship/"] openwisp-django-swappable-models-5f67b69/.github/ISSUE_TEMPLATE/000077500000000000000000000000001524564411500241575ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000011421524564411500266470ustar00rootroot00000000000000--- name: Bug report about: Open a bug report title: "[bug] " labels: bug assignees: "" --- **Describe the bug** A clear and concise description of the bug or unexpected behavior. **Steps To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **System Informatioon:** - OS: [e.g. Ubuntu 24.04 LTS] - Python Version: [e.g. Python 3.11.2] - Django Version: [e.g. Django 4.2.5] openwisp-django-swappable-models-5f67b69/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011451524564411500277050ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: "[feature] " labels: enhancement assignees: "" --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. openwisp-django-swappable-models-5f67b69/.github/ISSUE_TEMPLATE/question.md000066400000000000000000000005161524564411500263520ustar00rootroot00000000000000--- name: Question about: Please use the Discussion Forum to ask questions title: "[question] " labels: question assignees: "" --- Please use the [Discussion Forum](https://github.com/openwisp/django-swappable-models/discussions) to ask questions. We will take care of moving the discussion to a more relevant repository if needed. openwisp-django-swappable-models-5f67b69/.github/dependabot.yml000066400000000000000000000014741524564411500246320ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "monthly" commit-message: prefix: "[deps] " - package-ecosystem: "github-actions" # Check for GitHub Actions updates directory: "/" # The root directory where the Ansible role is located schedule: interval: "monthly" # Check for updates weekly commit-message: prefix: "[ci] " openwisp-django-swappable-models-5f67b69/.github/pull_request_template.md000066400000000000000000000016621524564411500267420ustar00rootroot00000000000000## Checklist - [ ] I have read the [OpenWISP Contributing Guidelines](https://openwisp.io/docs/dev/developer/contributing.html) and [OpenWISP Anti AI Spam Policy](https://openwisp.io/docs/dev/general/code-of-conduct.html#anti-ai-spam-policy). - [ ] I have manually tested the changes proposed in this pull request. - [ ] I have written new test cases for new code and/or updated existing tests for changes to existing code. - [ ] I have updated the documentation. ## Reference to Existing Issue Closes #. ## Description of Changes Please describe these changes. ## Screenshot Please include any relevant screenshots. openwisp-django-swappable-models-5f67b69/.github/workflows/000077500000000000000000000000001524564411500240315ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/.github/workflows/backport.yml000066400000000000000000000025101524564411500263570ustar00rootroot00000000000000name: Backport fixes to stable branch on: push: branches: - master issue_comment: types: [created] concurrency: group: backport-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false permissions: contents: read pull-requests: read jobs: backport-on-push: if: > github.repository == 'openwisp/django-swappable-models' && github.event_name == 'push' uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master with: commit_sha: ${{ github.sha }} secrets: app_id: ${{ secrets.OPENWISP_BOT_APP_ID }} private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} backport-on-comment: if: > github.repository == 'openwisp/django-swappable-models' && github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.issue.state == 'closed' && contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) && startsWith(github.event.comment.body, '/backport') uses: openwisp/openwisp-utils/.github/workflows/reusable-backport.yml@master with: pr_number: ${{ github.event.issue.number }} comment_body: ${{ github.event.comment.body }} secrets: app_id: ${{ secrets.OPENWISP_BOT_APP_ID }} private_key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} openwisp-django-swappable-models-5f67b69/.github/workflows/bot-autoassign-issue.yml000066400000000000000000000011361524564411500306420ustar00rootroot00000000000000name: Issue Assignment Bot on: issue_comment: types: [created] permissions: contents: read issues: write concurrency: group: bot-autoassign-issue-${{ github.repository }}-${{ github.event.issue.number }} cancel-in-progress: true jobs: respond-to-assign-request: if: github.event.issue.pull_request == null uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master with: bot_command: issue_assignment secrets: OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} openwisp-django-swappable-models-5f67b69/.github/workflows/bot-autoassign-pr-issue-link.yml000066400000000000000000000013171524564411500322150ustar00rootroot00000000000000name: PR Issue Auto-Assignment on: pull_request_target: types: [opened, reopened, closed, edited, ready_for_review] permissions: contents: read issues: write pull-requests: read concurrency: group: bot-autoassign-pr-link-${{ github.repository }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: auto-assign-issue: if: github.event.action != 'closed' || github.event.pull_request.merged == false uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master with: bot_command: issue_assignment secrets: OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} openwisp-django-swappable-models-5f67b69/.github/workflows/bot-autoassign-pr-reopen.yml000066400000000000000000000022411524564411500314170ustar00rootroot00000000000000name: PR Reopen Reassignment on: pull_request_target: types: [reopened] issue_comment: types: [created] permissions: contents: read issues: write pull-requests: write concurrency: group: bot-autoassign-pr-reopen-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: true jobs: reassign-on-reopen: if: github.event_name == 'pull_request_target' && github.event.action == 'reopened' uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master with: bot_command: pr_reopen secrets: OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} handle-pr-activity: if: github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.issue.user.login == github.event.comment.user.login uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master with: bot_command: pr_reopen secrets: OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} openwisp-django-swappable-models-5f67b69/.github/workflows/bot-autoassign-stale-pr.yml000066400000000000000000000010601524564411500312350ustar00rootroot00000000000000name: Stale PR Management on: schedule: - cron: "0 0 * * *" workflow_dispatch: permissions: contents: read issues: write pull-requests: write concurrency: group: bot-autoassign-stale-pr-${{ github.repository }} cancel-in-progress: false jobs: manage-stale-prs-python: uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-autoassign.yml@master with: bot_command: stale_pr secrets: OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} openwisp-django-swappable-models-5f67b69/.github/workflows/bot-changelog-runner.yml000066400000000000000000000030151524564411500305730ustar00rootroot00000000000000name: Changelog Bot Runner on: workflow_run: workflows: ["Changelog Bot Trigger"] types: - completed permissions: actions: read jobs: fetch-metadata: runs-on: ubuntu-latest if: github.event.workflow_run.conclusion == 'success' permissions: actions: read outputs: pr_number: ${{ steps.metadata.outputs.pr_number }} steps: - name: Download PR metadata id: download uses: actions/download-artifact@v8 with: name: changelog-metadata github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} continue-on-error: true - name: Read PR metadata if: steps.download.outcome == 'success' id: metadata run: | PR_NUMBER=$(cat pr_number) if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then echo "::error::Invalid PR number: $PR_NUMBER" exit 1 fi echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT changelog: needs: fetch-metadata if: needs.fetch-metadata.outputs.pr_number != '' permissions: contents: read pull-requests: write issues: write uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-changelog.yml@master with: pr_number: ${{ needs.fetch-metadata.outputs.pr_number }} secrets: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} OPENWISP_BOT_APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} OPENWISP_BOT_PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} openwisp-django-swappable-models-5f67b69/.github/workflows/bot-changelog-trigger.yml000066400000000000000000000021151524564411500307250ustar00rootroot00000000000000name: Changelog Bot Trigger on: pull_request_review: types: [submitted] permissions: {} jobs: check: if: | github.event.review.state == 'approved' && (github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'MEMBER' || github.event.review.author_association == 'COLLABORATOR') runs-on: ubuntu-latest steps: - name: Check for noteworthy PR id: check env: PR_TITLE: ${{ github.event.pull_request.title }} run: | if echo "$PR_TITLE" | grep -qiE '^\[(feature|fix|change!?)\]'; then echo "has_noteworthy=true" >> $GITHUB_OUTPUT fi - name: Save PR metadata if: steps.check.outputs.has_noteworthy == 'true' env: PR_NUMBER: ${{ github.event.pull_request.number }} run: echo "$PR_NUMBER" > pr_number - name: Upload PR metadata if: steps.check.outputs.has_noteworthy == 'true' uses: actions/upload-artifact@v7 with: name: changelog-metadata path: pr_number openwisp-django-swappable-models-5f67b69/.github/workflows/bot-ci-failure.yml000066400000000000000000000066251524564411500273670ustar00rootroot00000000000000name: CI Failure Bot on: workflow_run: workflows: ["Django Swappable Models Build"] types: - completed permissions: pull-requests: read actions: read contents: read concurrency: group: ci-failure-${{ github.repository }}-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.head_branch }} cancel-in-progress: true jobs: find-pr: runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event == 'pull_request' }} outputs: pr_number: ${{ steps.pr.outputs.number }} pr_author: ${{ steps.pr.outputs.author }} steps: - name: Find PR Number id: pr env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} PR_NUMBER_PAYLOAD: ${{ github.event.workflow_run.pull_requests[0].number }} EVENT_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} run: | emit_pr() { local pr_number="$1" local pr_author pr_author=$(gh pr view "$pr_number" --repo "$REPO" --json author --jq '.author.login // empty' 2>/dev/null || echo "") if [ -z "$pr_author" ] || [ "$pr_author" = "null" ]; then echo "::warning::Could not fetch PR author for PR #$pr_number" fi echo "number=$pr_number" >> "$GITHUB_OUTPUT" echo "author=$pr_author" >> "$GITHUB_OUTPUT" } PR_NUMBER="$PR_NUMBER_PAYLOAD" if [ -n "$PR_NUMBER" ]; then echo "Found PR #$PR_NUMBER from workflow payload." emit_pr "$PR_NUMBER" exit 0 fi HEAD_SHA="$EVENT_HEAD_SHA" echo "Payload empty. Searching for PR via Commits API..." PR_NUMBER=$(gh api repos/$REPO/commits/$HEAD_SHA/pulls -q '.[0].number' 2>/dev/null || true) if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then echo "Found PR #$PR_NUMBER using Commits API." emit_pr "$PR_NUMBER" exit 0 fi echo "API lookup failed/empty. Scanning open PRs for matching head SHA..." PR_NUMBER=$(gh pr list --repo "$REPO" --state open --limit 100 --json number,headRefOid --jq ".[] | select(.headRefOid == \"$HEAD_SHA\") | .number" | head -n 1) if [ -n "$PR_NUMBER" ]; then echo "Found PR #$PR_NUMBER by scanning open PRs." emit_pr "$PR_NUMBER" exit 0 fi echo "::warning::No open PR found. This workflow run might not be attached to an open PR." exit 0 call-ci-failure-bot: needs: find-pr if: ${{ needs.find-pr.outputs.pr_number != '' }} permissions: pull-requests: write actions: write contents: read uses: openwisp/openwisp-utils/.github/workflows/reusable-bot-ci-failure.yml@master with: pr_number: ${{ needs.find-pr.outputs.pr_number }} head_sha: ${{ github.event.workflow_run.head_sha }} head_repo: ${{ github.event.workflow_run.head_repository.full_name }} base_repo: ${{ github.repository }} run_id: ${{ github.event.workflow_run.id }} pr_author: ${{ needs.find-pr.outputs.pr_author }} actor: ${{ github.event.workflow_run.actor.login }} secrets: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} APP_ID: ${{ secrets.OPENWISP_BOT_APP_ID }} PRIVATE_KEY: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} openwisp-django-swappable-models-5f67b69/.github/workflows/ci.yml000066400000000000000000000016351524564411500251540ustar00rootroot00000000000000name: Django Swappable Models Build on: push: branches: - master pull_request: branches: - master jobs: build: name: Python==${{ matrix.python-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - "3.10" - "3.11" - "3.12" - "3.13" steps: - uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies id: deps run: | pip install -U -r requirements-test.txt - name: QA checks run: ./run-qa-checks - name: Test if: ${{ !cancelled() && steps.deps.conclusion == 'success' }} run: tox openwisp-django-swappable-models-5f67b69/.github/workflows/pypi.yml000066400000000000000000000013461524564411500255410ustar00rootroot00000000000000name: Publish Python Package to Pypi.org on: release: types: [published] permissions: id-token: write jobs: pypi-publish: name: Release Python Package on Pypi.org runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/swapper permissions: id-token: write steps: - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version: "3.10" - name: Install dependencies run: | pip install -U pip pip install build - name: Build package run: python -m build - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@v1.14.2 openwisp-django-swappable-models-5f67b69/.gitignore000066400000000000000000000001141524564411500224200ustar00rootroot00000000000000*.pyc .venv env *.egg-info *.egg *.sw? build dist README.rst /.eggs/ /.tox/ openwisp-django-swappable-models-5f67b69/AGENTS.md000066400000000000000000000235031524564411500217420ustar00rootroot00000000000000# AGENTS.md ## Project Overview `django-swappable-models` provides the `swapper` utility used by OpenWISP Django apps to support swappable models. Core code lives in `swapper/`: - `__init__.py` contains the public model loading and dependency helpers. - Tests live in `tests/`, including default and alternate apps for swapped-model behavior. ## Source of Truth - Use `README.md` for setup, package usage, and baseline test commands. - Use `.github/workflows/ci.yml` and `tox.ini` for CI-tested dependencies, QA/test commands, env vars, and supported Python/Django versions. - Use GitHub issue/PR templates when asked to open issues or PRs. If instructions conflict, repository config and CI workflows win first, docs next, and this file is supplemental. ## Contributing Guidelines - Before editing, inspect the relevant implementation, tests, documentation, and configuration. Follow existing repository patterns and do not invent behavior or requirements. - Keep each contribution focused and change only the lines necessary for its goal. Do not include unrelated refactors, formatting churn, or generated and dependency-file changes unless explicitly required. - Add or update focused tests for every behavior change. Use test-driven development when the scope is very clear, such as bug fixes or narrowly scoped changes. For new features, tests may be added after implementation, but confirm they fail when key feature code is removed. When a test failure does not clearly state the expected outcome that was not met, add an explicit assertion message. - Run `openwisp-qa-format` after each change when available. - Run the relevant targeted tests, builds, and documented QA checks, including `./run-qa-checks` when provided. Do not claim a change is complete when verification fails; report the failure or blocker. - When requirements, intended behavior, or an unexpected failure are unclear, stop and seek clarification instead of making speculative changes. - When starting work on a new issue, create a new branch from `master`. Use `issues/-` for issue work; otherwise, use a short, descriptive branch name. - Commit messages must be descriptive and use past tense. Past tense is a writing guideline that agents and contributors must follow; it is not checked automatically. For issue work, use an allowed prefix and a capitalized, past-tense subject ending with `#`, for example `[fix] Fixed perennial "modified" state #213`. Repeat the issue reference in the body with `Fixes`, `Closes`, `Resolves`, or `Related to` as appropriate. After creating a commit, use `openwisp-commit --check` to validate the current `HEAD`; it cannot validate a proposed message. Use `openwisp-commit --check --rev-range ` for an existing commit range, and `cz -n cz_openwisp info` to view allowed prefixes and message structure. - Add an explanatory commit body only for substantial changes, new features, or non-obvious bug fixes. The releaser automatically publishes the subject of `[feature]`, `[change]`, `[change!]`, `[deps]`, and `[fix]` commits, including scoped variants, in the changelog. Write those subjects in clear, user-friendly language suitable for release notes. - Send new commits in response to review feedback instead of amending existing commits. ## Development Rules - Follow the DRY principle: do not duplicate information or code across files. - Respect module boundaries and encapsulation. The module that owns a model, stored state, lifecycle, or domain invariant must expose the cohesive public operation that reads or changes it. Integrations must use that operation, not write its fields, coordinate multi-step changes to its internal state, or depend on its storage representation. Prefer behavior-oriented public APIs over setters for internal flags. When an integration needs a missing capability, add it to the owning module with invariant tests, then call it from the integration. - Preserve public APIs, import-time behavior, Django app registry behavior, migration dependency helpers, and backward compatibility unless explicitly required. - Place imports at the top of the file. Only defer imports when necessary (e.g., Django model imports inside functions or methods where the app registry is not yet ready). - Avoid unnecessary blank lines inside function and method bodies. - Prefer short, precise names that rely on their nearest meaningful scope. Do not repeat a feature, domain object, or namespace already named by the containing module, class, or function. For example, prefer `EstimatedLocation.refresh()` over `EstimatedLocation.refresh_estimated_location()`. Repeat that context only when the name is used outside that scope or is needed to distinguish genuinely different concepts. When a concise name cannot express a necessary distinction, use a concise docstring to describe it rather than encoding it in an excessively long name. - Before adding a comment or docstring, ask whether it conveys information a reader cannot reasonably infer from clear code, names, and surrounding scope. Add a concise comment when it explains a non-obvious reason, constraint, compatibility or security requirement, side effect, or unavoidable complexity. In opaque syntax or domain-specific code, especially shell scripts, a comment may also explain what the code does. Do not add comments that merely restate adjacent code one-to-one. - Update docs when behavior, settings, public APIs, setup steps, or supported versions change, including when a documented feature's behavior changes or a new user-facing feature is added. ## Testing and QA - When separate tests cover different cases of the same feature, share almost identical setup, and primarily vary in input or expected outcome, group them in one test method with `subTest`. Keep each subtest's setup explicit and independent, and retain separate test methods when cases exercise genuinely distinct behavior. Leave one blank line before each with `self.subTest(...)` statement only when a test method contains multiple such statements. Do not add a blank line for a single `subTest` statement inside a loop. - For focused tests, call `./tests/manage.py test ` directly. Use `./runtests` only for the full suite because it runs multiple coverage and integration configurations and is not a focused-test runner. - Prefer in-process tests so coverage tools can measure changed code. - Keep helpers and classes used by only one test method inside that method. Promote them to class or module scope only when genuinely reused. - Keep tests quiet on success. When code under test writes to stdout or stderr, use `capture_stdout`, `capture_stderr`, or `capture_any_output` from `openwisp_utils.tests` and assert the expected output. Do not leave unasserted output, logs, or warnings in test runs. ## Django Rules - Build internal URLs with named URL patterns and `reverse()` or `reverse_lazy()`, including in tests. Use the appropriate namespace and URL arguments. - In the main behavior test for non-trivial, frequently called views, include `assertNumQueries()` with representative data to enforce an intentional query budget and catch N+1 queries. Use `AssertNumQueriesSubTestMixin` from `openwisp_utils.tests` where available: it records the query-count check as a subtest, so subsequent assertions in the method still run. Change the expected count only when the extra queries are necessary and understood. - When processing all records from a queryset in commands, tasks, imports, exports, migrations, or similar operations, use `QuerySet.iterator()` or another bounded method. Do not load all matching records into memory at once. - When accessing related objects while processing a queryset, use `select_related()` or `prefetch_related()` as needed to avoid N+1 queries. `prefetch_related()` used with `iterator()` requires an explicit `chunk_size`; otherwise use `iterator()` with its default behavior. - Paginate API list responses that can return many records. Do not return every matching record in one response unless the maximum result size is defined and safely bounded. - Prefer bulk writes when writing many objects. Use `bulk_create()` or `bulk_update()` unless model validation, `save()` behavior, signals, or another requirement prevents it. Explain the reason for not using a bulk write in a nearby code comment. - Test custom pagination, custom batching, and code that consumes paginated APIs beyond the first page or batch. Do not repeat coverage already provided by an unchanged shared pagination class or utility. - Before defining a new class, view, URL, REST endpoint, or test layout, inspect analogous implementations in related OpenWISP modules. Match their established names, URL names, API shape, and test organization unless the behavior requires a difference. - Cover both default and swapped-model apps when changing model resolution, dependency generation, or settings handling. - Be careful with import timing, circular imports, app labels, migration dependencies, and Django version compatibility. - Treat email addresses as case-insensitive when identifying, deduplicating, importing, migrating, or searching users by email. Use `email__iexact` for direct and `Q()` ORM lookups. Keep username matching case-sensitive unless explicitly required. Normalize email records this module owns to lowercase, and cover casing-only inputs, including legacy mixed-case records when relevant. ## Security Rules - Watch for unsafe dynamic imports, incorrect model resolution, and secrets in tests or docs. ## Troubleshooting - If documentation and CI commands differ, use CI for verification and report the exact documentation path, CI workflow path, and differing commands. Do not change the documentation until the user explicitly chooses one of these actions: update the named documentation file in the current change because the divergence was caused by that change, or leave it unchanged for a separate follow-up. Never decide that scope distinction independently. openwisp-django-swappable-models-5f67b69/CHANGES.rst000066400000000000000000000056221524564411500222430ustar00rootroot00000000000000Changelog ========= Version 1.5.0 [2026-09-01] -------------------------- Changes ~~~~~~~ Dependencies ++++++++++++ - Added support for Django ``5.1`` and ``5.2``, - Added support for Python ``3.11``, ``3.12`` and ``3.13``. - Dropped support for Python < 3.9. - Dropped support for Django < 4.2. Version 1.4.0 [2024-08-14] -------------------------- Changes ~~~~~~~ Dependencies ++++++++++++ - Added support for Django ``4.2`` and ``5.0``. - Dropped support for Django ``Django 4.0a1``. - Added support for Python ``3.10`` and ``3.11``. - Dropped support for Python ``3.7``. Verson 1.3.0 [2021-11-29] ------------------------- - [change] Allow possibility to point swappable dependency to specific migration number (instead of only to ``__latest__``) Version 1.2.0 [2021-11-12] -------------------------- - [feature] Add possibility to point swappable dependency to ``__latest__`` - [change] Added support for Python 3.9 - [change] Added support for Django 3.2 and Django 4.0a1 - [change] Dropped support for old Django versions (<2.2) - [change] Dropped support for old Python versions (<3.7) - [feature] Added optional ``require_ready`` argument to ``load_model`` function Version 1.1.2 [2020-01-15] -------------------------- - [deps] Verified support for python 3.8 - [deps] Added support for Django 3.0 and Django Rest Framework 3.11 Version 1.1.1 [2019-07-23] -------------------------- - [deps] Drop python<3.3 support - [deps] Added support for python 3.7 - [deps] Django 2 support added Version 1.1.0 [2017-05-11] -------------------------- - [test] Added tests for swapper.split - `#13 `_ [fix] Handle contrib apps and apps with dot in app_label. Version 1.0.0 [2016-08-26] -------------------------- - [docs] Improved usuability docs - `86e238 `_: [deps] Compatibility with django 1.10 added Version 0.3.0 [2015-11-17] -------------------------- - `#9 `_ [deps] Added support for django 1.9 Version 0.2.2 [2015-06-16] -------------------------- - [deps] Added support for django~=1.6.0 - [deps] Added support for Python 3.3 - [docs] Fix model reference in README - [docs] Notes for load_model initialization (`for more info see #2 `_) Version 0.2.1 [2014-11-18] -------------------------- - [docs] Added examples for migration scripts - [docs] Documented use of Functions - [fix] Fixed Lookup Error in load_model Version 0.2.0 [2014-09-13] -------------------------- - [deps] Added support for Django 1.7 - [feature] Added `swapper.dependency` function. - [tests] Added tests Version 0.1.1 [2014-01-09] -------------------------- - [docs] Added References Version 0.1.0 [2014-01-09] -------------------------- - Added base functions for swapping models openwisp-django-swappable-models-5f67b69/LICENSE000066400000000000000000000021301524564411500214350ustar00rootroot00000000000000Copyright (c) 2021, OpenWISP Copyright (c) 2014-2017, S. Andrew Sheppard, http://wq.io/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. openwisp-django-swappable-models-5f67b69/README.md000066400000000000000000000331471524564411500217230ustar00rootroot00000000000000# Swapper [![Dependency monitoring](https://img.shields.io/librariesio/release/github/openwisp/django-swappable-models)](https://libraries.io/github/openwisp/django-swappable-models) #### Django Swappable Models - No longer only for auth.User! Swapper is an unofficial API for the [undocumented] but very powerful Django feature: swappable models. Swapper facilitates implementing arbitrary swappable models in your own reusable apps. [![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) [![Latest PyPI Release](https://img.shields.io/pypi/v/swapper.svg)](https://pypi.org/project/swapper) [![Release Notes](https://img.shields.io/github/release/openwisp/django-swappable-models.svg)](https://github.com/wq/django-swappable-models/releases) [![License](https://img.shields.io/pypi/l/swapper.svg)](https://github.com/openwisp/django-swappable-models/blob/master/LICENSE) [![GitHub Stars](https://img.shields.io/github/stars/openwisp/django-swappable-models.svg)](https://github.com/openwisp/django-swappable-models/stargazers) [![GitHub Forks](https://img.shields.io/github/forks/openwisp/django-swappable-models.svg)](https://github.com/openwisp/django-swappable-models/network) ## Requirements - Python 3.10 or later - Django 4.2 or later ## Motivation Suppose your reusable app has two related tables: ```python from django.db import models class Parent(models.Model): name = models.TextField() class Child(models.Model): name = models.TextField() parent = models.ForeignKey(Parent, on_delete=models.CASCADE) ``` Suppose further that you want to allow the user to subclass either or both of these models and supplement them with their own additional fields. You could use Abstract classes (e.g. `BaseParent` and `BaseChild`) for this, but then you would either need to: 1. Avoid putting the foreign key on `BaseChild` and tell the user they need to do it. 2. Put the foreign key on `BaseChild`, but make `Parent` a concrete model that can't be swapped 3. Use swappable models, together with `ForeignKeys` that read the swappable settings. 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. 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.) ### Real-World Examples Swapper is used extensively in several OpenWISP packages to facilitate customization and extension. Notable examples include: - [openwisp-users] - [openwisp-controller] - [openwisp-radius] The use of swapper in these packages promotes [Software Reusability][reusability], one of the core values of the OpenWISP project. ## Creating a Reusable App 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. ```bash pip3 install swapper ``` Extending the above example, you might create two abstract base classes and corresponding default implementations: ```python # reusableapp/models.py from django.db import models import swapper class BaseParent(models.Model): # minimal base implementation ... class Meta: abstract = True class Parent(BaseParent): # default (swappable) implementation ... class Meta: swappable = swapper.swappable_setting('reusableapp', 'Parent') class BaseChild(models.Model): parent = models.ForeignKey( swapper.get_model_name('reusableapp', 'Parent'), on_delete=models.CASCADE ) # minimal base implementation ... class Meta: abstract = True class Child(BaseChild): # default (swappable) implementation ... class Meta: swappable = swapper.swappable_setting('reusableapp', 'Child') ``` ### Loading Swapped Models 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. ```python # reusableapp/views.py # Might work, might not # from .models import Parent import swapper Parent = swapper.load_model("reusableapp", "Parent") Child = swapper.load_model("reusableapp", "Child") def view(request, *args, **kwargs): qs = Parent.objects.all() # ... ``` > 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. ### Migration Scripts 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. ```diff # reusableapp/migrations/0001_initial.py from django.db import models, migrations < from django.conf import settings > import swapper class Migration(migrations.Migration): dependencies = [ < migrations.swappable_dependency(settings.REUSABLEAPP_PARENT_MODEL), > swapper.dependency('reusableapp', 'Parent') ] operations = [ migrations.CreateModel( name='Child', fields=[ ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), ], options={ < 'swappable': 'REUSABLEAPP_CHILD_MODEL', > 'swappable': swapper.swappable_setting('reusableapp', 'Child'), }, bases=(models.Model,), ), migrations.CreateModel( name='Parent', fields=[ ('id', models.AutoField(auto_created=True, serialize=False, primary_key=True, verbose_name='ID')), ], options={ < 'swappable': 'REUSABLEAPP_PARENT_MODEL', > 'swappable': swapper.swappable_setting('reusableapp', 'Parent'), }, bases=(models.Model,), ), migrations.AddField( model_name='child', name='parent', < field=models.ForeignKey(to=settings.REUSABLEAPP_PARENT_MODEL, on_delete=models.CASCADE), > field=models.ForeignKey(to=swapper.get_model_name('reusableapp', 'Parent'), on_delete=models.CASCADE), preserve_default=True, ), ] ``` ## End User Documentation 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: ```python # myapp/models.py from reusableapp.models import BaseParent class Parent(BaseParent): # custom implementation ... ``` Then, tell your users to update their settings to trigger the swap. ```python # myproject/settings.py REUSABLEAPP_PARENT_MODEL = "myapp.Parent" ``` 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. 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]). ## API Documentation 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. | function | purpose | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `swappable_setting(app_label, model)` | Generates a swappable setting name for the provided model (e.g. `"REUSABLEAPP_PARENT_MODEL"`) | | `is_swapped(app_label, model)` | Determines whether or not a given model has been swapped. (Returns the model name if swapped, otherwise `False`) | | `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.) | | `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). | | `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`. | | `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. | | `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. | | `join(app_label, model)`, `split(model)` | Utilities for splitting and joining `"app.Model"` strings and `("app", "Model")` tuples. | [undocumented]: https://code.djangoproject.com/ticket/19103 [swapping the auth.User model]: https://docs.djangoproject.com/en/4.0/topics/auth/customizing/#auth-custom-user [openwisp-users]: https://github.com/openwisp/openwisp-users#extend-openwisp-users [openwisp-controller]: https://github.com/openwisp/openwisp-controller#extending-openwisp-controller [openwisp-radius]: https://openwisp-radius.readthedocs.io/en/latest/developer/how_to_extend.html [reusability]: https://openwisp.io/docs/general/values.html#software-reusability-means-long-term-sustainability [get_user_model()]: https://docs.djangoproject.com/en/4.0/topics/auth/customizing/#referencing-the-user-model [#10]: https://github.com/openwisp/django-swappable-models/issues/10 [Django ticket #25313]: https://code.djangoproject.com/ticket/25313 [drawbacks]: https://code.djangoproject.com/ticket/23071 openwisp-django-swappable-models-5f67b69/requirements-test.txt000066400000000000000000000001021524564411500246660ustar00rootroot00000000000000pip wheel setuptools tox tox-gh-actions openwisp-utils[qa]~=1.3.0 openwisp-django-swappable-models-5f67b69/run-qa-checks000077500000000000000000000000771524564411500230270ustar00rootroot00000000000000#!/bin/bash set -e openwisp-qa-check \ --skip-checkmigrations openwisp-django-swappable-models-5f67b69/setup.cfg000066400000000000000000000004721524564411500222600ustar00rootroot00000000000000[bdist_wheel] universal=1 [flake8] max-line-length = 110 # W503: line break before or after operator # W504: line break after or after operator # W605: invalid escape sequence ignore = W605, W503, W504 [isort] multi_line_output=3 use_parentheses=True include_trailing_comma=True force_grid_wrap=0 line_length=88 openwisp-django-swappable-models-5f67b69/setup.py000066400000000000000000000025201524564411500221450ustar00rootroot00000000000000from setuptools import setup LONG_DESCRIPTION = """ The unofficial Django swappable models API. """ def readme(): try: with open("README.md", encoding="utf-8") as f: return f.read() except IOError: return LONG_DESCRIPTION setup( name="swapper", use_scm_version=True, setup_requires=["setuptools_scm"], author="S. Andrew Sheppard", author_email="andrew@wq.io", url="https://github.com/openwisp/django-swappable-models", license="MIT", packages=["swapper"], description=LONG_DESCRIPTION.strip(), long_description=readme(), long_description_content_type="text/markdown", classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", ], tests_require=["django>=4.2"], python_requires=">=3.10", ) openwisp-django-swappable-models-5f67b69/swapper/000077500000000000000000000000001524564411500221155ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/swapper/__init__.py000066400000000000000000000055041524564411500242320ustar00rootroot00000000000000from django.apps import apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db.migrations import swappable_dependency _prefixes = {} def swappable_setting(app_label, model): """Returns the setting name to use for the given model Returns the setting name to use for the given model (i.e. AUTH_USER_MODEL) """ prefix = _prefixes.get(app_label, app_label) setting = "{prefix}_{model}_MODEL".format( prefix=prefix.upper(), model=model.upper() ) # Ensure this attribute exists to avoid migration issues in Django 1.7 if not hasattr(settings, setting): setattr(settings, setting, join(app_label, model)) return setting def is_swapped(app_label, model): """Returns the value of the swapped setting. Returns the value of the swapped setting, or False if the model hasn't been swapped. """ default_model = join(app_label, model) setting = swappable_setting(app_label, model) value = getattr(settings, setting, default_model) if value != default_model: return value else: return False def get_model_name(app_label, model): """Returns [app_label.model]. Returns [app_label.model] unless the model has been swapped, in which case returns the swappable setting value. """ return is_swapped(app_label, model) or join(app_label, model) def dependency(app_label, model, version=None): """Returns a Django 1.7+ style dependency tuple Returns a Django 1.7+ style dependency tuple for inclusion in migration.dependencies[] """ dependencies = swappable_dependency(get_model_name(app_label, model)) if not version: return dependencies return dependencies[0], version def get_model_names(app_label, models): """Map model names to their swapped equivalents for the given app""" return dict((model, get_model_name(app_label, model)) for model in models) def load_model(app_label, model, required=True, require_ready=True): """Load the specified model class, or the class it was swapped out for.""" swapped = is_swapped(app_label, model) if swapped: app_label, model = split(swapped) try: cls = apps.get_model(app_label, model, require_ready=require_ready) except LookupError: cls = None if cls is None and required: raise ImproperlyConfigured( "Could not find {name}!".format(name=join(app_label, model)) ) return cls def set_app_prefix(app_label, prefix): """Set a custom prefix to use for the given app (e.g. WQ)""" _prefixes[app_label] = prefix def join(app_label, model): return "{app_label}.{model}".format( app_label=app_label, model=model, ) def split(model): app_label, _, model = model.rpartition(".") return app_label, model openwisp-django-swappable-models-5f67b69/tests/000077500000000000000000000000001524564411500215765ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/tests/__init__.py000066400000000000000000000007311524564411500237100ustar00rootroot00000000000000import os import django from django.core.management import call_command from django.test.utils import setup_test_environment os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") setup_test_environment() django.setup() call_command("makemigrations", "default_app", interactive=False) if os.environ["DJANGO_SETTINGS_MODULE"] == "tests.swap_settings": call_command("makemigrations", "alt_app", interactive=False) call_command("migrate", interactive=False) openwisp-django-swappable-models-5f67b69/tests/alt_app/000077500000000000000000000000001524564411500232165ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/tests/alt_app/__init__.py000066400000000000000000000000001524564411500253150ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/tests/alt_app/models.py000066400000000000000000000002611524564411500250520ustar00rootroot00000000000000from django.db import models from tests.default_app.models import BaseType class Type(BaseType): code = models.SlugField() class Meta: app_label = "alt_app" openwisp-django-swappable-models-5f67b69/tests/default_app/000077500000000000000000000000001524564411500240625ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/tests/default_app/__init__.py000066400000000000000000000000001524564411500261610ustar00rootroot00000000000000openwisp-django-swappable-models-5f67b69/tests/default_app/models.py000066400000000000000000000007601524564411500257220ustar00rootroot00000000000000from django.db import models import swapper class BaseType(models.Model): name = models.CharField(max_length=255) class Meta: abstract = True class Type(BaseType): class Meta: swappable = swapper.swappable_setting("default_app", "Type") class Item(models.Model): type = models.ForeignKey( swapper.get_model_name("default_app", "Type"), on_delete=models.CASCADE ) name = models.CharField(max_length=255) description = models.TextField() openwisp-django-swappable-models-5f67b69/tests/settings.py000066400000000000000000000003031524564411500240040ustar00rootroot00000000000000SECRET_KEY = "1234" INSTALLED_APPS = ("tests.default_app",) MIDDLEWARE_CLASSES = tuple() DATABASES = { "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}, } SWAP = False openwisp-django-swappable-models-5f67b69/tests/swap_settings.py000066400000000000000000000004131524564411500250400ustar00rootroot00000000000000from . import settings DEFAULT_APP_TYPE_MODEL = "alt_app.Type" MIDDLEWARE_CLASSES = settings.MIDDLEWARE_CLASSES DATABASES = settings.DATABASES INSTALLED_APPS = settings.INSTALLED_APPS INSTALLED_APPS += ("tests.alt_app",) SECRET_KEY = settings.SECRET_KEY SWAP = True openwisp-django-swappable-models-5f67b69/tests/test_swapper.py000066400000000000000000000074421524564411500246770ustar00rootroot00000000000000import unittest from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.test import TestCase import swapper try: from django.db import migrations # noqa except ImportError: DJ17 = False else: DJ17 = True class SwapperTestCase(TestCase): # Tests that should work whether or not default_app.Type is swapped def test_fields(self): Type = swapper.load_model("default_app", "Type") fields = dict((field.name, field) for field in Type._meta.fields) self.assertIn("name", fields) def test_create(self): Type = swapper.load_model("default_app", "Type") Item = swapper.load_model("default_app", "Item") Item.objects.create( type=Type.objects.create(name="Type 1"), name="Item 1", ) self.assertEqual(Item.objects.count(), 1) item = Item.objects.all()[0] self.assertEqual(item.type.name, "Type 1") def test_not_installed(self): Invalid = swapper.load_model("invalid_app", "Invalid", required=False) self.assertIsNone(Invalid) with self.assertRaises(ImproperlyConfigured): swapper.load_model("invalid_app", "Invalid", required=True) def test_non_contrib_app_split(self): self.assertEqual(swapper.split("alt_app.Type"), ("alt_app", "Type")) def test_contrib_app_split(self): self.assertEqual( swapper.split("alt_app.contrib.named_things.NamedThing"), ("alt_app.contrib.named_things", "NamedThing"), ) # Tests that only work if default_app.Type is swapped @unittest.skipUnless(settings.SWAP, "requires swapped models") def test_swap_setting(self): self.assertTrue(swapper.is_swapped("default_app", "Type")) self.assertEqual(swapper.get_model_name("default_app", "Type"), "alt_app.Type") @unittest.skipUnless(settings.SWAP, "requires swapped models") def test_swap_fields(self): Type = swapper.load_model("default_app", "Type") fields = dict((field.name, field) for field in Type._meta.fields) self.assertIn("code", fields) @unittest.skipUnless(settings.SWAP, "requires swapped models") def test_swap_create(self): Type = swapper.load_model("default_app", "Type") Item = swapper.load_model("default_app", "Item") Item.objects.create( type=Type.objects.create( name="Type 1", code="type-1", ), name="Item 1", ) self.assertEqual(Item.objects.count(), 1) item = Item.objects.all()[0] self.assertEqual(item.type.code, "type-1") @unittest.skipUnless(settings.SWAP and DJ17, "requires swapped models & Django 1.7") def test_swap_dependency(self): self.assertEqual( swapper.dependency("default_app", "Type"), ("alt_app", "__first__") ) self.assertEqual( swapper.dependency("default_app", "Type", "__latest__"), ("alt_app", "__latest__"), ) self.assertEqual( swapper.dependency("default_app", "Type", "0001_custom_migration"), ("alt_app", "0001_custom_migration"), ) # Tests that only work if default_app.Type is *not* swapped @unittest.skipIf(settings.SWAP, "requires non-swapped models") def test_default_setting(self): self.assertFalse(swapper.is_swapped("default_app", "Type")) self.assertEqual( swapper.get_model_name("default_app", "Type"), "default_app.Type" ) @unittest.skipUnless( not settings.SWAP and DJ17, "requires non-swapped models & Django 1.7" ) def test_default_dependency(self): self.assertEqual( swapper.dependency("default_app", "Type"), ("default_app", "__first__"), ) openwisp-django-swappable-models-5f67b69/tox.ini000066400000000000000000000015431524564411500217520ustar00rootroot00000000000000[tox] envlist = py{310,311,312}-django42-{noswap, swap} py{310,311,312}-django50-{noswap, swap} py{310,311,312,313}-django51-{noswap, swap} py{310,311,312,313}-django52-{noswap, swap} lint [gh-actions] python = 3.10: py310, lint 3.11: py311, lint 3.12: py312, lint 3.13: py313, lint [testenv] commands = rm -rf tests/default_app/migrations/ tests/alt_app/migrations/ python -m unittest discover -s tests deps = django42: django~=4.2.0 django50: django~=5.0.0 django51: django~=5.1.0 django52: django~=5.2.0rc setenv = noswap: DJANGO_SETTINGS_MODULE=tests.settings swap: DJANGO_SETTINGS_MODULE=tests.swap_settings allowlist_externals = rm [testenv:lint] commands = rm -rf tests/default_app/migrations/ tests/alt_app/migrations/ flake8 swapper tests allowlist_externals = rm flake8