├── .all-contributorsrc ├── .bumpversion.cfg ├── .coveragerc ├── .editorconfig ├── .fussyfox.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ └── feature_request.yaml └── workflows │ ├── auto-approve.yml │ ├── auto-merge.yml │ ├── codacy-analysis.yml │ ├── deploy.yml │ ├── gh-pages.yml │ ├── greetings.yml │ ├── migration-fixer.yml │ ├── test.yml │ └── update-doc-assets.yml ├── .gitignore ├── .pep8speaks.yml ├── .pre-commit-config.yaml ├── .pyup.yml ├── .whitesource ├── .yamllint ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Duplicate-action.png ├── Duplicate-button.png ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── django_clone ├── __init__.py ├── settings.py └── wsgi.py ├── docs ├── .nojekyll ├── CHANGELOG.md ├── Duplicate-action.png ├── Duplicate-button.png ├── README.md ├── _sidebar.md └── index.html ├── manage.py ├── model_clone ├── __init__.py ├── admin.py ├── apps.py ├── locale │ ├── en_US │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── fr │ │ └── LC_MESSAGES │ │ └── django.po ├── mixin.py ├── models.py ├── signals.py ├── templates │ ├── admin │ │ └── change_form_object_tools.html │ └── clone │ │ └── change_form.html ├── tests │ ├── __init__.py │ ├── test_clone_mixin.py │ ├── test_clone_signals.py │ ├── test_create_copy_of_instance.py │ └── test_utils.py └── utils.py ├── renovate.json ├── requirements.txt ├── sample ├── __init__.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── create_default_user.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_library.py │ ├── 0003_book_created_at.py │ ├── 0004_auto_20191122_0848.py │ ├── 0005_page.py │ ├── 0006_assignment.py │ ├── 0007_auto_20200829_0213.py │ ├── 0008_page_created_at.py │ ├── 0009_auto_20210407_1546.py │ ├── 0010_furniture_house_room.py │ ├── 0011_auto_20210414_1744.py │ ├── 0012_backcover_cover.py │ ├── 0013_edition.py │ ├── 0014_auto_20210422_1449.py │ ├── 0015_auto_20210423_0935.py │ ├── 0016_product.py │ ├── 0017_auto_20210624_1117.py │ ├── 0018_auto_20210628_2301.py │ ├── 0019_saletag_sale_tag_unique_name.py │ ├── 0020_auto_20210717_2230.py │ ├── 0021_book_custom_slug.py │ ├── 0022_ending_sentence.py │ ├── 0023_alter_edition_book.py │ ├── 0024_alter_edition_book.py │ ├── 0025_auto_20221029_0402.py │ └── __init__.py ├── models.py ├── signals.py └── urls.py ├── sample_assignment ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_contract_id.py │ └── __init__.py └── models.py ├── sample_company ├── __init__.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_companydepot_id.py │ └── __init__.py └── models.py ├── sample_driver ├── __init__.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_driver_id.py │ ├── 0003_driverflag_driver_flags.py │ └── __init__.py └── models.py ├── setup.cfg ├── setup.py └── tox.ini /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "gerbyzation", 10 | "name": "Gerben Neven", 11 | "avatar_url": "https://avatars1.githubusercontent.com/u/2500973?v=4", 12 | "profile": "http://gerritneven.nl", 13 | "contributions": [ 14 | "bug", 15 | "test", 16 | "code" 17 | ] 18 | }, 19 | { 20 | "login": "SebastianKapunkt", 21 | "name": "Sebastian Kapunkt", 22 | "avatar_url": "https://avatars1.githubusercontent.com/u/2536081?v=4", 23 | "profile": "http://sebastian-kindt.com", 24 | "contributions": [ 25 | "code", 26 | "bug", 27 | "test" 28 | ] 29 | }, 30 | { 31 | "login": "andresp99999", 32 | "name": "Andrés Portillo", 33 | "avatar_url": "https://avatars0.githubusercontent.com/u/1036725?v=4", 34 | "profile": "https://github.com/andresp99999", 35 | "contributions": [ 36 | "bug" 37 | ] 38 | }, 39 | { 40 | "login": "renovate-bot", 41 | "name": "WhiteSource Renovate", 42 | "avatar_url": "https://avatars0.githubusercontent.com/u/25180681?v=4", 43 | "profile": "https://renovate.whitesourcesoftware.com", 44 | "contributions": [ 45 | "maintenance" 46 | ] 47 | }, 48 | { 49 | "login": "yuekui", 50 | "name": "Yuekui", 51 | "avatar_url": "https://avatars2.githubusercontent.com/u/2561450?v=4", 52 | "profile": "https://github.com/yuekui", 53 | "contributions": [ 54 | "code", 55 | "bug", 56 | "test", 57 | "doc", 58 | "maintenance" 59 | ] 60 | }, 61 | { 62 | "login": "diesieben07", 63 | "name": "Take Weiland", 64 | "avatar_url": "https://avatars.githubusercontent.com/u/1915984?v=4", 65 | "profile": "https://github.com/diesieben07", 66 | "contributions": [ 67 | "test", 68 | "bug", 69 | "code" 70 | ] 71 | }, 72 | { 73 | "login": "ptrck", 74 | "name": "Patrick", 75 | "avatar_url": "https://avatars.githubusercontent.com/u/1259703?v=4", 76 | "profile": "https://github.com/ptrck", 77 | "contributions": [ 78 | "bug", 79 | "code" 80 | ] 81 | }, 82 | { 83 | "login": "Akollek", 84 | "name": "Amiel Kollek", 85 | "avatar_url": "https://avatars.githubusercontent.com/u/5873158?v=4", 86 | "profile": "https://github.com/Akollek", 87 | "contributions": [ 88 | "code", 89 | "bug", 90 | "test" 91 | ] 92 | }, 93 | { 94 | "login": "erictheise", 95 | "name": "Eric Theise", 96 | "avatar_url": "https://avatars.githubusercontent.com/u/317680?v=4", 97 | "profile": "https://erictheise.com/", 98 | "contributions": [ 99 | "doc" 100 | ] 101 | }, 102 | { 103 | "login": "DanielSchaffer", 104 | "name": "Daniel Schaffer", 105 | "avatar_url": "https://avatars.githubusercontent.com/u/334487?v=4", 106 | "profile": "https://github.com/DanielSchaffer", 107 | "contributions": [ 108 | "code", 109 | "test" 110 | ] 111 | }, 112 | { 113 | "login": "DamianB-BitFlipper", 114 | "name": "Damian Barabonkov", 115 | "avatar_url": "https://avatars.githubusercontent.com/u/4206989?v=4", 116 | "profile": "http://damianb.dev", 117 | "contributions": [ 118 | "code", 119 | "test" 120 | ] 121 | } 122 | ], 123 | "contributorsPerLine": 7, 124 | "projectName": "django-clone", 125 | "projectOwner": "tj-django", 126 | "repoType": "github", 127 | "repoHost": "https://github.com", 128 | "skipCi": true, 129 | "commitConvention": "angular", 130 | "commitType": "docs" 131 | } 132 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 5.3.3 3 | commit = True 4 | tag = False 5 | 6 | [bumpversion:file:setup.py] 7 | search = version="{current_version}" 8 | replace = version="{new_version}" 9 | 10 | [bumpversion:file:model_clone/__init__.py] 11 | search = __version__ = "{current_version}" 12 | replace = __version__ = "{new_version}" 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = model_clone 3 | omit = 4 | model_clone/test*, 5 | model_clone/admin* 6 | 7 | [report] 8 | precision = 1 9 | exclude_lines = 10 | pragma: no cover 11 | pass 12 | def __repr__ 13 | if self.debug: 14 | if settings.DEBUG 15 | # Don't complain if tests don't hit defensive assertion code: 16 | raise AssertionError 17 | raise StopIteration 18 | raise NotImplementedError 19 | except ImportError 20 | except IntegrityError 21 | # Don't complain if non-runnable code isn't run: 22 | if __name__ == .__main__.: 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,css,scss,json,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /.fussyfox.yml: -------------------------------------------------------------------------------- 1 | - flake8 2 | # - pydocstyle 3 | # - bandit TODO: Switch to use github action 4 | # - isort 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jackton1 4 | patreon: # Replace with a single Patreon username 5 | open_collective: tj-django 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: [] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug 2 | description: Create a report to help us improve 3 | title: "[BUG] " 4 | labels: [bug, needs triage] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this bug report! 11 | - type: checkboxes 12 | attributes: 13 | label: Is there an existing issue for this? 14 | description: Please search to see if an issue already exists for the bug you encountered. 15 | options: 16 | - label: I have searched the existing issues 17 | required: true 18 | - type: checkboxes 19 | attributes: 20 | label: Does this issue exist in the latest version? 21 | description: Please view all releases to confirm that this issue hasn't already been fixed. 22 | options: 23 | - label: I'm using the latest release 24 | required: true 25 | - type: textarea 26 | id: what-happened 27 | attributes: 28 | label: Describe the bug? 29 | description: A clear and concise description of what the bug is 30 | placeholder: Tell us what you see! 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: reproduce 35 | attributes: 36 | label: To Reproduce 37 | description: Steps to reproduce the behavior? 38 | placeholder: | 39 | 1. In this environment... 40 | 2. With this config... 41 | 3. Run '...' 42 | 4. See error... 43 | validations: 44 | required: true 45 | - type: dropdown 46 | id: os 47 | attributes: 48 | label: What OS are you seeing the problem on? 49 | multiple: true 50 | options: 51 | - Ubuntu 52 | - macOS 53 | - Windows 54 | - Other 55 | validations: 56 | required: false 57 | - type: textarea 58 | id: expected 59 | attributes: 60 | label: Expected behavior? 61 | description: A clear and concise description of what you expected to happen. 62 | placeholder: Tell us what you expected! 63 | validations: 64 | required: true 65 | - type: textarea 66 | id: logs 67 | attributes: 68 | label: Relevant log output 69 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 70 | placeholder: | 71 | This can be achieved by: 72 | 1. Re-running the workflow with debug logging enabled. 73 | 2. Copy or download the log archive. 74 | 3. Paste the contents here or upload the file in a subsequent comment. 75 | render: shell 76 | - type: textarea 77 | attributes: 78 | label: Anything else? 79 | description: | 80 | Links? or References? 81 | 82 | Anything that will give us more context about the issue you are encountering! 83 | 84 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 85 | validations: 86 | required: false 87 | - type: checkboxes 88 | id: terms 89 | attributes: 90 | label: Code of Conduct 91 | description: By submitting this issue, you agree to follow our [Code of Conduct](../blob/main/CODE_OF_CONDUCT.md) 92 | options: 93 | - label: I agree to follow this project's Code of Conduct 94 | required: true 95 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: "[Feature] <title>" 4 | labels: [enhancement] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this feature request! 11 | - type: checkboxes 12 | attributes: 13 | label: Is this feature missing in the latest version? 14 | description: Please upgrade to the latest version to verify that this feature is still missing. 15 | options: 16 | - label: I'm using the latest release 17 | required: true 18 | - type: textarea 19 | id: what-happened 20 | attributes: 21 | label: Is your feature request related to a problem? Please describe. 22 | description: | 23 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 24 | placeholder: Tell us what you see! 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: requests 29 | attributes: 30 | label: Describe the solution you'd like? 31 | description: A clear and concise description of what you want to happen. 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: alternative 36 | attributes: 37 | label: Describe alternatives you've considered? 38 | description: A clear and concise description of any alternative solutions or features you've considered. 39 | validations: 40 | required: false 41 | - type: textarea 42 | attributes: 43 | label: Anything else? 44 | description: | 45 | Links? or References? 46 | 47 | Add any other context or screenshots about the feature request here. 48 | 49 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 50 | validations: 51 | required: false 52 | - type: checkboxes 53 | id: terms 54 | attributes: 55 | label: Code of Conduct 56 | description: By submitting this issue, you agree to follow our [Code of Conduct](./CODE_OF_CONDUCT.md) 57 | options: 58 | - label: I agree to follow this project's Code of Conduct 59 | required: true 60 | -------------------------------------------------------------------------------- /.github/workflows/auto-approve.yml: -------------------------------------------------------------------------------- 1 | name: Auto approve 2 | 3 | on: 4 | pull_request_target 5 | 6 | jobs: 7 | auto-approve: 8 | runs-on: ubuntu-latest 9 | if: | 10 | ( 11 | github.event.pull_request.user.login == 'dependabot[bot]' || 12 | github.event.pull_request.user.login == 'dependabot' || 13 | github.event.pull_request.user.login == 'dependabot-preview[bot]' || 14 | github.event.pull_request.user.login == 'dependabot-preview' || 15 | github.event.pull_request.user.login == 'renovate[bot]' || 16 | github.event.pull_request.user.login == 'renovate' || 17 | github.event.pull_request.user.login == 'github-actions[bot]' || 18 | github.event.pull_request.user.login == 'pre-commit-ci' || 19 | github.event.pull_request.user.login == 'pre-commit-ci[bot]' 20 | ) 21 | && 22 | ( 23 | github.actor == 'dependabot[bot]' || 24 | github.actor == 'dependabot' || 25 | github.actor == 'dependabot-preview[bot]' || 26 | github.actor == 'dependabot-preview' || 27 | github.actor == 'renovate[bot]' || 28 | github.actor == 'renovate' || 29 | github.actor == 'github-actions[bot]' || 30 | github.actor == 'pre-commit-ci' || 31 | github.actor == 'pre-commit-ci[bot]' 32 | ) 33 | steps: 34 | - uses: hmarr/auto-approve-action@v4 35 | with: 36 | github-token: ${{ secrets.PAT_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Auto-merge 2 | on: 3 | pull_request_target: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.actor == 'dependabot[bot]' }} 15 | steps: 16 | - name: Dependabot metadata 17 | id: metadata 18 | uses: dependabot/fetch-metadata@v2 19 | with: 20 | github-token: "${{ secrets.GITHUB_TOKEN }}" 21 | - name: Enable auto-merge for Dependabot PRs 22 | if: | 23 | ( 24 | steps.metadata.outputs.update-type == 'version-update:semver-minor' || 25 | steps.metadata.outputs.update-type == 'version-update:semver-patch' 26 | ) 27 | run: gh pr merge --auto --squash "$PR_URL" 28 | env: 29 | PR_URL: ${{github.event.pull_request.html_url}} 30 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 31 | pre-commit-ci: 32 | runs-on: ubuntu-latest 33 | if: ${{ github.event.pull_request.user.login == 'pre-commit-ci[bot]' }} 34 | steps: 35 | - name: Enable auto-merge for Pre-commit PRs 36 | run: gh pr merge --auto --squash "$PR_URL" 37 | env: 38 | PR_URL: ${{github.event.pull_request.html_url}} 39 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 40 | -------------------------------------------------------------------------------- /.github/workflows/codacy-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow checks out code, performs a Codacy security scan 2 | # and integrates the results with the 3 | # GitHub Advanced Security code scanning feature. For more information on 4 | # the Codacy security scan action usage and parameters, see 5 | # https://github.com/codacy/codacy-analysis-cli-action. 6 | # For more information on Codacy Analysis CLI in general, see 7 | # https://github.com/codacy/codacy-analysis-cli. 8 | 9 | name: Codacy Security Scan 10 | 11 | on: 12 | push: 13 | branches: [ main ] 14 | pull_request: 15 | # The branches below must be a subset of the branches above 16 | branches: [ main ] 17 | schedule: 18 | - cron: '15 16 * * 2' 19 | 20 | jobs: 21 | codacy-security-scan: 22 | name: Codacy Security Scan 23 | runs-on: ubuntu-latest 24 | steps: 25 | # Checkout the repository to the GitHub Actions runner 26 | - name: Checkout code 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 28 | 29 | # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis 30 | - name: Run Codacy Analysis CLI 31 | uses: codacy/codacy-analysis-cli-action@v4.4.5 32 | with: 33 | # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository 34 | # You can also omit the token and run the tools that support default configurations 35 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 36 | verbose: true 37 | output: results.sarif 38 | format: sarif 39 | # Adjust severity of non-security issues 40 | gh-code-scanning-compat: true 41 | # Force 0 exit code to allow SARIF file generation 42 | # This will handover control about PR rejection to the GitHub side 43 | max-allowed-issues: 2147483647 44 | 45 | # Upload the SARIF file generated in the previous step 46 | - name: Upload SARIF results file 47 | uses: github/codeql-action/upload-sarif@v3 48 | with: 49 | sarif_file: results.sarif 50 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Run semver-diff 20 | id: semver-diff 21 | uses: tj-actions/semver-diff@v3.0.1 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.13.x' 27 | 28 | - name: Upgrade pip 29 | run: | 30 | pip install -U pip 31 | 32 | - name: Install dependencies 33 | run: make install-deploy 34 | 35 | - name: Setup git 36 | run: | 37 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 38 | git config --local user.name "github-actions[bot]" 39 | 40 | - name: bumpversion 41 | run: | 42 | make increase-version PART="${{ steps.semver-diff.outputs.release_type }}" 43 | 44 | - name: Build and publish 45 | run: make release 46 | env: 47 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 48 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 49 | 50 | - name: Generate CHANGELOG 51 | uses: tj-actions/github-changelog-generator@v1.21 52 | 53 | - name: Create Pull Request 54 | uses: peter-evans/create-pull-request@v7 55 | with: 56 | base: "main" 57 | title: "Upgraded ${{ steps.semver-diff.outputs.old_version }} → ${{ steps.semver-diff.outputs.new_version }}" 58 | branch: "chore/upgrade-${{ steps.semver-diff.outputs.old_version }}-to-${{ steps.semver-diff.outputs.new_version }}" 59 | commit-message: "Upgraded from ${{ steps.semver-diff.outputs.old_version }} → ${{ steps.semver-diff.outputs.new_version }}" 60 | body: "View [CHANGES](https://github.com/${{ github.repository }}/compare/${{ steps.semver-diff.outputs.old_version }}...${{ steps.semver-diff.outputs.new_version }})" 61 | token: ${{ secrets.GITHUB_TOKEN }} 62 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Github pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Deploy 17 | uses: peaceiris/actions-gh-pages@v4.0.0 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | publish_dir: ./docs 21 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request_target, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/first-interaction@v1 10 | with: 11 | repo-token: ${{ secrets.GITHUB_TOKEN }} 12 | issue-message: "Thanks for reporting this issue, don't forget to star this project if you haven't already to help us reach a wider audience." 13 | pr-message: "Thanks for implementing a fix, could you ensure that the test covers your changes if applicable." 14 | -------------------------------------------------------------------------------- /.github/workflows/migration-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Fix django migrations 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | fix-migrations: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 13 | with: 14 | fetch-depth: 0 15 | persist-credentials: false 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.13.x' 21 | cache: 'pip' 22 | cache-dependency-path: '**/requirements.txt' 23 | 24 | - name: Install dependencies 25 | run: | 26 | make install-test 27 | 28 | - name: Run django-migration-fixer 29 | uses: tj-django/django-migration-fixer@v1.3.6 30 | 31 | - name: Verify Changed files 32 | uses: tj-actions/verify-changed-files@v20 33 | id: verify-changed-files 34 | with: 35 | files: | 36 | sample/migrations 37 | sample_assignment/migrations 38 | sample_driver/migrations 39 | sample_company/migrations 40 | 41 | - name: Commit migration changes 42 | if: steps.verify-changed-files.outputs.files_changed == 'true' 43 | run: | 44 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 45 | git config --local user.name "github-actions[bot]" 46 | git add sample/migrations sample_assignment/migrations sample_driver/migrations sample_company/migrations 47 | git commit -m "Update migrations" 48 | 49 | - name: Push migration changes 50 | if: steps.verify-changed-files.outputs.files_changed == 'true' 51 | uses: ad-m/github-push-action@master 52 | with: 53 | github_token: ${{ secrets.PAT_TOKEN }} 54 | branch: ${{ github.head_ref }} 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | paths: 11 | - '**' 12 | 13 | jobs: 14 | cleanup-runs: 15 | runs-on: ubuntu-latest 16 | if: "!startsWith(github.ref, 'refs/tags/') && !endsWith(github.ref, 'main')" 17 | steps: 18 | - uses: rokroskar/workflow-run-cleanup-action@v0.3.3 19 | env: 20 | GITHUB_TOKEN: ${{ github.token }} 21 | 22 | build: 23 | runs-on: ${{ matrix.platform }} 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | platform: [ubuntu-latest, macos-latest, windows-latest] 28 | python-version: [3.7, 3.8, 3.9, '3.10', 3.11] 29 | exclude: 30 | - platform: macos-latest 31 | python-version: 3.11 32 | - platform: macos-latest 33 | python-version: 3.7 34 | - platform: windows-latest 35 | python-version: 3.11 36 | - platform: ubuntu-latest 37 | python-version: 3.7 38 | 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | 42 | - name: Set up Python ${{ matrix.python-version }} 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | cache: 'pip' 47 | cache-dependency-path: '**/requirements.txt' 48 | 49 | - name: Install dependencies 50 | run: | 51 | make install-test 52 | 53 | - name: Run test 54 | run: make tox 55 | env: 56 | CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} 57 | PLATFORM: ${{ matrix.platform }} 58 | 59 | - name: "Upload coverage to Codecov" 60 | if: ${{ runner.os == 'Linux' && matrix.python-version == '3.9' }} 61 | uses: codecov/codecov-action@v5.4.3 62 | with: 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | fail_ci_if_error: true 65 | -------------------------------------------------------------------------------- /.github/workflows/update-doc-assets.yml: -------------------------------------------------------------------------------- 1 | name: Sync doc assets 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | sync-doc-assets: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Run test 21 | uses: tj-actions/remark@v3 22 | 23 | - name: Verify Changed files 24 | uses: tj-actions/verify-changed-files@v20 25 | id: verify_changed_files 26 | with: 27 | files: | 28 | README.md 29 | 30 | - name: README.md changed 31 | if: steps.verify_changed_files.outputs.files_changed == 'true' 32 | run: | 33 | echo "README.md has uncommitted changes" 34 | exit 1 35 | 36 | - name: Create Pull Request 37 | if: failure() 38 | uses: peter-evans/create-pull-request@v7 39 | with: 40 | base: "main" 41 | labels: merge when passing 42 | title: "Updated README.md" 43 | branch: "chore/update-readme" 44 | commit-message: "Updated README.md" 45 | body: "Updated README.md" 46 | token: ${{ secrets.GITHUB_TOKEN }} 47 | 48 | - name: Copy doc assets 49 | run: | 50 | cp -f README.md docs/README.md 51 | cp -f CHANGELOG.md docs/CHANGELOG.md 52 | cp -f *.png docs 53 | 54 | - name: Create Pull Request 55 | uses: peter-evans/create-pull-request@v7.0.8 56 | with: 57 | commit-message: Synced README changes to docs 58 | committer: github-actions[bot] <github-actions[bot]@users.noreply@github.com> 59 | author: github-actions[bot] <github-actions[bot]@users.noreply.github.com> 60 | branch: chore/update-docs 61 | labels: merge when passing 62 | base: main 63 | delete-branch: true 64 | title: Updated docs 65 | body: | 66 | Updated docs 67 | - Auto-generated by github-actions[bot] 68 | assignees: jackton1 69 | reviewers: jackton1 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # pytype static type analyzer 136 | .pytype/ 137 | 138 | # Cython debug symbols 139 | cython_debug/ 140 | 141 | # JetBrains PyCharm 142 | .idea/ 143 | 144 | # Test Results 145 | test-reports/ 146 | 147 | .DS_Store 148 | -------------------------------------------------------------------------------- /.pep8speaks.yml: -------------------------------------------------------------------------------- 1 | scanner: 2 | diff_only: True # If False, the entire file touched by the Pull Request is scanned for errors. If True, only the diff is scanned. 3 | linter: pycodestyle 4 | 5 | pycodestyle: # Same as scanner.linter value. Other option is flake8 6 | max-line-length: 100 # Default is 79 in PEP 8 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/autoflake 3 | rev: v2.3.1 4 | hooks: 5 | - id: autoflake 6 | args: ['--in-place', '--remove-all-unused-imports', '--remove-unused-variable'] 7 | 8 | - repo: https://github.com/pycqa/isort 9 | rev: 6.0.1 10 | hooks: 11 | - id: isort 12 | args: ["--profile", "black", "--filter-files"] 13 | 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v5.0.0 16 | hooks: 17 | - id: trailing-whitespace 18 | exclude: ^docs/.*|.*.md 19 | - id: end-of-file-fixer 20 | exclude: ^docs/.*|.*.md 21 | 22 | - repo: https://github.com/psf/black 23 | rev: 25.1.0 24 | hooks: 25 | - id: black 26 | language_version: python3 27 | 28 | - repo: https://github.com/adrienverge/yamllint.git 29 | rev: v1.37.1 30 | hooks: 31 | - id: yamllint 32 | args: ["-d", "relaxed"] 33 | 34 | - repo: https://github.com/pycqa/flake8 35 | rev: 7.2.0 36 | hooks: 37 | - id: flake8 38 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # autogenerated pyup.io config file 2 | # see https://pyup.io/docs/configuration/ for all available options 3 | 4 | schedule: '' 5 | update: False 6 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "scanSettings": { 3 | "baseBranches": [] 4 | }, 5 | "checkRunSettings": { 6 | "vulnerableCheckRunConclusionLevel": "failure", 7 | "displayMode": "diff" 8 | }, 9 | "issueSettings": { 10 | "minSeverityLevel": "LOW" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | extends: default 2 | 3 | rules: 4 | document-start: disable 5 | colons: disable 6 | truthy: disable 7 | comments-indentation: disable 8 | comments: disable 9 | 10 | line-length: 11 | max: 136 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | jtonye@ymail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Duplicate-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/Duplicate-action.png -------------------------------------------------------------------------------- /Duplicate-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/Duplicate-button.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tonye Jack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include model_clone/templates *.html 2 | recursive-include model_clone *.py 3 | recursive-exclude model_clone/tests *.py 4 | prune django_clone 5 | prune sample_assignment 6 | prune sample_company 7 | prune sample_driver 8 | prune sample 9 | prune .github 10 | prune .circleci 11 | global-exclude __pycache__ 12 | global-exclude *.py[co] 13 | 14 | include *.md 15 | exclude *.example 16 | exclude *.json 17 | exclude .yamllint 18 | exclude *.png 19 | exclude *.egg-info 20 | include *.py 21 | include *.txt 22 | exclude .bumpversion.cfg 23 | exclude .coveragerc 24 | include LICENSE 25 | exclude Makefile 26 | exclude tox.ini 27 | exclude manage.py 28 | exclude .tox 29 | exclude .all-contributorsrc 30 | exclude .pypirc 31 | exclude *.yml 32 | 33 | recursive-exclude docs *.html 34 | recursive-exclude docs *.md 35 | recursive-exclude docs *.nojekyll 36 | recursive-exclude docs *.png 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Self-Documented Makefile see https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 2 | 3 | .DEFAULT_GOAL := help 4 | 5 | PYTHON := /usr/bin/env python 6 | PYTHON_VERSION := $(PYTHON) --version 7 | MANAGE_PY := $(PYTHON) manage.py 8 | PYTHON_PIP := /usr/bin/env pip 9 | PIP_COMPILE := /usr/bin/env pip-compile 10 | PART := patch 11 | PACKAGE_VERSION = $(shell $(PYTHON) setup.py --version) 12 | 13 | # Put it first so that "make" without argument is like "make help". 14 | help: 15 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-32s-\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 16 | 17 | .PHONY: help 18 | 19 | guard-%: ## Checks that env var is set else exits with non 0 mainly used in CI; 20 | @if [ -z '${${*}}' ]; then echo 'Environment variable $* not set' && exit 1; fi 21 | 22 | # -------------------------------------------------------- 23 | # ------- Python package (pip) management commands ------- 24 | # -------------------------------------------------------- 25 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 26 | 27 | clean-build: ## remove build artifacts 28 | @rm -fr build/ 29 | @rm -fr dist/ 30 | @rm -fr .eggs/ 31 | @find . -name '*.egg-info' -exec rm -fr {} + 32 | @find . -name '*.egg' -exec rm -f {} + 33 | 34 | clean-pyc: ## remove Python file artifacts 35 | @find . -name '*.pyc' -exec rm -f {} + 36 | @find . -name '*.pyo' -exec rm -f {} + 37 | @find . -name '*~' -exec rm -f {} + 38 | @find . -name '__pycache__' -exec rm -fr {} + 39 | 40 | clean-test: ## remove test and coverage artifacts 41 | @rm -fr .tox/ 42 | @rm -f .coverage 43 | @rm -fr htmlcov/ 44 | @rm -fr .pytest_cache 45 | 46 | install-wheel: ## Install wheel 47 | @echo "Installing wheel..." 48 | @pip install wheel 49 | 50 | install: clean requirements.txt install-wheel ## Install project dependencies. 51 | @echo "Installing project in dependencies..." 52 | @$(PYTHON_PIP) install -r requirements.txt 53 | 54 | install-lint: clean setup.py install-wheel ## Install lint extra dependencies. 55 | @echo "Installing lint extra requirements..." 56 | @$(PYTHON_PIP) install -e .'[lint]' 57 | 58 | install-test: clean setup.py install-wheel ## Install test extra dependencies. 59 | @echo "Installing test extra requirements..." 60 | @$(PYTHON_PIP) install -e .'[test]' 61 | 62 | install-deploy: clean setup.py install-wheel ## Install deploy extra dependencies. 63 | @echo "Installing deploy extra requirements..." 64 | @$(PYTHON_PIP) install -e .'[deploy]' 65 | 66 | install-dev: clean setup.py install-wheel ## Install development extra dependencies. 67 | @echo "Installing development requirements..." 68 | @$(PYTHON_PIP) install -e .'[development]' 69 | 70 | update-requirements: ## Updates the requirements.txt adding missing package dependencies 71 | @echo "Syncing the package requirements.txt..." 72 | @$(PIP_COMPILE) 73 | 74 | # -------------------------------------------------------- 75 | # ------- Django manage.py commands --------------------- 76 | # -------------------------------------------------------- 77 | migrations: 78 | @$(MANAGE_PY) makemigrations 79 | 80 | migrate: 81 | @$(MANAGE_PY) migrate 82 | 83 | default-user: migrate 84 | @echo "Creating a default user..." 85 | @$(MANAGE_PY) create_default_user 86 | @echo "Username: admin@admin.com" 87 | @echo "Password: admin" 88 | 89 | run: default-user 90 | @echo "Starting server..." 91 | @$(MANAGE_PY) runserver 92 | 93 | makemessages: clean-build ## Runs over the entire source tree of the current directory and pulls out all strings marked for translation. 94 | @$(MANAGE_PY) makemessages --locale=en_US --ignore=sample --ignore=django_clone 95 | @$(MANAGE_PY) makemessages --locale=fr --ignore=sample --ignore=django_clone 96 | 97 | compilemessages: makemessages ## Compiles .po files created by makemessages to .mo files for use with the built-in gettext support. 98 | @$(MANAGE_PY) compilemessages --ignore=.tox --ignore=sample --ignore=django_clone 99 | 100 | test: 101 | @echo "Running `$(PYTHON_VERSION)` test..." 102 | @$(MANAGE_PY) test 103 | 104 | # ---------------------------------------------------------- 105 | # ---------- Release the project to PyPI ------------------- 106 | # ---------------------------------------------------------- 107 | increase-version: guard-PART ## Increase project version 108 | @bump2version $(PART) 109 | @git switch -c main 110 | 111 | dist: clean ## builds source and wheel package 112 | @pip install build twine 113 | @python -m build 114 | 115 | release: dist ## package and upload a release 116 | @twine upload dist/* 117 | 118 | # ---------------------------------------------------------- 119 | # --------- Run project Test ------------------------------- 120 | # ---------------------------------------------------------- 121 | tox: install-test ## Run tox test 122 | @tox 123 | 124 | clean-test-all: clean-build ## Clean build and test assets. 125 | @rm -rf .tox/ 126 | @rm -rf test-results 127 | @rm -rf .pytest_cache/ 128 | @rm -f test.db 129 | @rm -f ".coverage.*" .coverage coverage.xml 130 | 131 | 132 | # ----------------------------------------------------------- 133 | # --------- Fix lint errors --------------------------------- 134 | # ----------------------------------------------------------- 135 | lint-fix: ## Run black with inplace for model_clone and sample/models.py. 136 | @pip install black autopep8 137 | @black model_clone sample/models.py sample_driver sample_assignment sample_company --line-length=95 138 | @autopep8 -ir model_clone sample/models.py sample_driver sample_assignment sample_company --max-line-length=95 139 | 140 | # ----------------------------------------------------------- 141 | # --------- Docs --------------------------------------- 142 | # ----------------------------------------------------------- 143 | serve-docs: 144 | @npm i -g docsify-cli 145 | @npx docsify serve ./docs 146 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <p align="center"> 2 | <img width="466" alt="4FC889E9-FF59-4E44-9EB6-2AF7DC034C74" src="https://user-images.githubusercontent.com/17484350/215616634-17439a58-7bd8-4e9c-989f-e6bef7c73e48.png"> 3 | </p> 4 | 5 | | Python | Django | Downloads | Code Style | 6 | |:---------:|:-------:|:-----------:|:--------------:| 7 | | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django_clone.svg)](https://pypi.org/project/django-clone) | [![PyPI - Django Version](https://img.shields.io/pypi/djversions/django_clone.svg)](https://docs.djangoproject.com/en/dev/releases/) | [![Downloads](https://static.pepy.tech/badge/django-clone)](https://pepy.tech/project/django-clone) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | 8 | 9 | | PyPI | Test | Vulnerabilities | Coverage | Code Quality | Pre-Commit | 10 | |:---------------:|:----:|:---------------:|:--------:|:-------------:|:-------------:| 11 | | [![PyPI version](https://badge.fury.io/py/django-clone.svg)](https://badge.fury.io/py/django-clone) | [![Test](https://github.com/tj-django/django-clone/workflows/Test/badge.svg)](https://github.com/tj-django/django-clone/actions?query=workflow%3ATest) | [![Known Vulnerabilities](https://snyk.io/test/github/tj-django/django-clone/badge.svg?targetFile=requirements.txt)](https://snyk.io/test/github/tj-django/django-clone?targetFile=requirements.txt) | [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/b33dd02dbb034d7fa9886a99f5383ea6)](https://www.codacy.com/gh/tj-django/django-clone?utm_source=github.com\&utm_medium=referral\&utm_content=tj-django/django-clone\&utm_campaign=Badge_Coverage) <br/> [![codecov](https://codecov.io/gh/tj-django/django-clone/branch/main/graph/badge.svg?token=2NE21Oe50Q)](https://codecov.io/gh/tj-django/django-clone)| [![Codacy Badge](https://app.codacy.com/project/badge/Grade/b33dd02dbb034d7fa9886a99f5383ea6)](https://www.codacy.com/gh/tj-django/django-clone?utm_source=github.com\&utm_medium=referral\&utm_content=tj-django/django-clone\&utm_campaign=Badge_Grade) | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/tj-django/django-clone/main.svg)](https://results.pre-commit.ci/latest/github/tj-django/django-clone/main) | 12 | 13 | ## django-clone 14 | 15 | Create copies of a model instance with explicit control on how the instance should be duplicated (limiting fields or related objects copied) with unique field detection. 16 | 17 | This solves the problem introduced by using `instance.pk = None` and `instance.save()` which results in copying more object state than required. 18 | 19 | ## Features 20 | 21 | * 100% test coverage. 22 | * More control over how a model instance should be duplicated 23 | * Multi Database support i.e Create duplicates on one or more databases. 24 | * Restrict fields used for creating a duplicate instance. 25 | * Detects unique fields and naively adds a suffix `copy {count}` to each duplicate instance (for supported fields only). 26 | * Optionally differentiate between a duplicate instance and the original by appending a **copy** suffix to non unique fields (for supported fields only). 27 | 28 | ## Table of Contents 29 | 30 | * [Installation](#installation) 31 | * [pip](#pip) 32 | * [poetry](#poetry) 33 | * [Usage](#usage) 34 | * [Subclassing the `CloneModel`](#subclassing-the-clonemodel) 35 | * [Using the `CloneMixin`](#using-the-clonemixin) 36 | * [Using the `CloneModel`](#using-the-clonemodel) 37 | * [Duplicating a model instance](#duplicating-a-model-instance) 38 | * [Bulk cloning a model](#bulk-cloning-a-model) 39 | * [Creating clones without subclassing `CloneMixin`.](#creating-clones-without-subclassing-clonemixin) 40 | * [CloneMixin attributes](#clonemixin-attributes) 41 | * [Explicit (include only these fields)](#explicit-include-only-these-fields) 42 | * [Implicit (include all except these fields)](#implicit-include-all-except-these-fields) 43 | * [Django Admin](#django-admin) 44 | * [Duplicating Models from the Django Admin view.](#duplicating-models-from-the-django-admin-view) 45 | * [List View](#list-view) 46 | * [Change View](#change-view) 47 | * [CloneModelAdmin class attributes](#clonemodeladmin-class-attributes) 48 | * [Advanced Usage](#advanced-usage) 49 | * [Signals](#signals) 50 | * [`pre_clone_save`, `post_clone_save`](#pre_clone_save-post_clone_save) 51 | * [Multi-database support](#multi-database-support) 52 | * [Compatibility](#compatibility) 53 | * [Running locally](#running-locally) 54 | * [Found a Bug?](#found-a-bug) 55 | * [Contributors ✨](#contributors-) 56 | 57 | ## Installation 58 | 59 | ### pip 60 | 61 | ```bash 62 | pip install django-clone 63 | ``` 64 | 65 | ### poetry 66 | 67 | ```bash 68 | poetry add django-clone 69 | ``` 70 | 71 | ## Usage 72 | 73 | ### Subclassing the `CloneModel` 74 | 75 | ![](https://user-images.githubusercontent.com/17484350/221387430-efd5508a-2597-4320-9750-5a4c56833edb.png) 76 | 77 | ### Using the `CloneMixin` 78 | 79 | ![](https://user-images.githubusercontent.com/17484350/221387397-6ad5475b-6887-4a5f-b6d3-42784f9dfa7c.png) 80 | 81 | ### Using the `CloneModel` 82 | 83 | ![](https://user-images.githubusercontent.com/17484350/221387226-572cedbe-e30e-456d-af75-bcd25edec754.png) 84 | 85 | ### Duplicating a model instance 86 | 87 | ![](https://user-images.githubusercontent.com/17484350/221386600-731a6f45-1704-4834-bcbe-0f57d912faf7.png) 88 | 89 | ### Bulk cloning a model 90 | 91 | ![](https://user-images.githubusercontent.com/17484350/221386555-13978280-35a1-4941-8186-a1c6723a0346.png) 92 | 93 | ### Creating clones without subclassing `CloneMixin`. 94 | 95 | > **NOTE:** :warning: 96 | > 97 | > * This method won't copy over related objects like Many to Many/One to Many relationships. 98 | > * Ensure that required fields skipped from being cloned are passed in using the `attrs` kwargs. 99 | 100 | ![](https://user-images.githubusercontent.com/17484350/221385171-add1a0c3-21fc-4c48-bfe9-4f2014ffe035.png) 101 | 102 | ### CloneMixin attributes 103 | 104 | | Attribute | Description | 105 | |:------------------------------:|:------------:| 106 | | `DUPLICATE_SUFFIX` | Suffix to append to duplicates <br> (NOTE: This requires `USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS` <br> to be enabled and supports string fields). | 107 | `USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS` | Enable appending the `DUPLICATE_SUFFIX` to new cloned instances. | 108 | `UNIQUE_DUPLICATE_SUFFIX` | Suffix to append to unique fields | 109 | `USE_UNIQUE_DUPLICATE_SUFFIX` | Enable appending the `UNIQUE_DUPLICATE_SUFFIX` to new cloned instances. | 110 | `MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS` | The max query attempt while generating unique values for a case of unique conflicts. | 111 | 112 | #### Explicit (include only these fields) 113 | 114 | | Attribute | Description | 115 | |:------------------------------:|:------------:| 116 | | `_clone_fields` | Restrict the list of fields to copy from the instance (By default: Copies all fields excluding auto-created/non editable model fields) | 117 | | `_clone_m2m_fields` | Restricted Many to many fields (i.e Test.tags) | 118 | | `_clone_m2o_or_o2m_fields` | Restricted Many to One/One to Many fields | 119 | | `_clone_o2o_fields` | Restricted One to One fields | 120 | | `_clone_linked_m2m_fields` | Restricted Many to Many fields that should be linked to the new instance | 121 | 122 | #### Implicit (include all except these fields) 123 | 124 | | Attribute | Description | 125 | |:--------------------:|:-----------:| 126 | | `_clone_excluded_fields` | Excluded model fields. | 127 | `_clone_excluded_m2m_fields` | Excluded many to many fields. | 128 | `_clone_excluded_m2o_or_o2m_fields` | Excluded Many to One/One to Many fields. | 129 | `_clone_excluded_o2o_fields` | Excluded one to one fields. | 130 | 131 | > **NOTE:** :warning: 132 | > 133 | > * Ensure to either set `_clone_excluded_*` or `_clone_*`. Using both would raise errors. 134 | 135 | ### Django Admin 136 | 137 | #### Duplicating Models from the Django Admin view. 138 | 139 | ![](https://user-images.githubusercontent.com/17484350/221386874-047989a4-ae4d-4d82-9ef6-2b303001a4c2.png) 140 | 141 | ##### List View 142 | 143 | ![Screenshot](Duplicate-action.png) 144 | 145 | ##### Change View 146 | 147 | ![Screenshot](Duplicate-button.png) 148 | 149 | #### CloneModelAdmin class attributes 150 | 151 | ![](https://user-images.githubusercontent.com/17484350/221387085-e0ca31ee-8c4c-40d9-9ce6-44ff5e6814ff.png) 152 | 153 | > **NOTE:** :warning: 154 | > 155 | > * Ensure that `model_clone` is placed before `django.contrib.admin` 156 | 157 | ```python 158 | INSTALLED_APPS = [ 159 | 'model_clone', 160 | 'django.contrib.admin', 161 | '...', 162 | ] 163 | ``` 164 | 165 | ## Advanced Usage 166 | 167 | ### Signals 168 | 169 | #### `pre_clone_save`, `post_clone_save` 170 | 171 | ![](https://user-images.githubusercontent.com/17484350/221387120-b5219cdb-9f74-4751-b593-2c68db9fd0e0.png) 172 | 173 | ### Multi-database support 174 | 175 | ![](https://user-images.githubusercontent.com/17484350/221385217-3a123080-b247-4ef0-b876-e75db1518c92.png) 176 | 177 | ## Compatibility 178 | 179 | | Python | Supported version | 180 | |--------------|--------------------| 181 | | Python2.x | `<=2.5.3` | 182 | | Python3.5 | `<=2.9.6` | 183 | | Python3.6+ | `<=5.3.3` | 184 | | Python3.7+ | All versions | 185 | 186 | | Django | Supported version | 187 | |--------------|---------------------| 188 | | 1.11 | `<=2.7.2` | 189 | | 2.x | All versions | 190 | | 3.x | All versions | 191 | | 4.x | All versions | 192 | 193 | ## Running locally 194 | 195 | ```shell 196 | $ git clone git@github.com:tj-django/django-clone.git 197 | $ make default-user 198 | $ make run 199 | ``` 200 | 201 | Spins up a django server running the demo app. 202 | 203 | Visit http://127.0.0.1:8000 204 | 205 | ## Found a Bug? 206 | 207 | To file a bug or submit a patch, please head over to [django-clone on github](https://github.com/tj-django/django-clone/issues). 208 | 209 | If you feel generous and want to show some extra appreciation: 210 | 211 | Support me with a :star: 212 | 213 | [![Buy me a coffee][buymeacoffee-shield]][buymeacoffee] 214 | 215 | [buymeacoffee]: https://www.buymeacoffee.com/jackton1 216 | 217 | [buymeacoffee-shield]: https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png 218 | 219 | ## Contributors ✨ 220 | 221 | Thanks goes to these wonderful people: 222 | 223 | <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section --> 224 | 225 | <!-- prettier-ignore-start --> 226 | 227 | <!-- markdownlint-disable --> 228 | 229 | <table> 230 | <tbody> 231 | <tr> 232 | <td align="center" valign="top" width="14.28%"><a href="http://gerritneven.nl"><img src="https://avatars1.githubusercontent.com/u/2500973?v=4?s=100" width="100px;" alt="Gerben Neven"/><br /><sub><b>Gerben Neven</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/issues?q=author%3Agerbyzation" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=gerbyzation" title="Tests">⚠️</a> <a href="https://github.com/tj-django/django-clone/commits?author=gerbyzation" title="Code">💻</a></td> 233 | <td align="center" valign="top" width="14.28%"><a href="http://sebastian-kindt.com"><img src="https://avatars1.githubusercontent.com/u/2536081?v=4?s=100" width="100px;" alt="Sebastian Kapunkt"/><br /><sub><b>Sebastian Kapunkt</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=SebastianKapunkt" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/issues?q=author%3ASebastianKapunkt" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=SebastianKapunkt" title="Tests">⚠️</a></td> 234 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/andresp99999"><img src="https://avatars0.githubusercontent.com/u/1036725?v=4?s=100" width="100px;" alt="Andrés Portillo"/><br /><sub><b>Andrés Portillo</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/issues?q=author%3Aandresp99999" title="Bug reports">🐛</a></td> 235 | <td align="center" valign="top" width="14.28%"><a href="https://renovate.whitesourcesoftware.com"><img src="https://avatars0.githubusercontent.com/u/25180681?v=4?s=100" width="100px;" alt="WhiteSource Renovate"/><br /><sub><b>WhiteSource Renovate</b></sub></a><br /><a href="#maintenance-renovate-bot" title="Maintenance">🚧</a></td> 236 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/yuekui"><img src="https://avatars2.githubusercontent.com/u/2561450?v=4?s=100" width="100px;" alt="Yuekui"/><br /><sub><b>Yuekui</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=yuekui" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/issues?q=author%3Ayuekui" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=yuekui" title="Tests">⚠️</a> <a href="https://github.com/tj-django/django-clone/commits?author=yuekui" title="Documentation">📖</a> <a href="#maintenance-yuekui" title="Maintenance">🚧</a></td> 237 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/diesieben07"><img src="https://avatars.githubusercontent.com/u/1915984?v=4?s=100" width="100px;" alt="Take Weiland"/><br /><sub><b>Take Weiland</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=diesieben07" title="Tests">⚠️</a> <a href="https://github.com/tj-django/django-clone/issues?q=author%3Adiesieben07" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=diesieben07" title="Code">💻</a></td> 238 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/ptrck"><img src="https://avatars.githubusercontent.com/u/1259703?v=4?s=100" width="100px;" alt="Patrick"/><br /><sub><b>Patrick</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/issues?q=author%3Aptrck" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=ptrck" title="Code">💻</a></td> 239 | </tr> 240 | <tr> 241 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/Akollek"><img src="https://avatars.githubusercontent.com/u/5873158?v=4?s=100" width="100px;" alt="Amiel Kollek"/><br /><sub><b>Amiel Kollek</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=Akollek" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/issues?q=author%3AAkollek" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=Akollek" title="Tests">⚠️</a></td> 242 | <td align="center" valign="top" width="14.28%"><a href="https://erictheise.com/"><img src="https://avatars.githubusercontent.com/u/317680?v=4?s=100" width="100px;" alt="Eric Theise"/><br /><sub><b>Eric Theise</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=erictheise" title="Documentation">📖</a></td> 243 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/DanielSchaffer"><img src="https://avatars.githubusercontent.com/u/334487?v=4?s=100" width="100px;" alt="Daniel Schaffer"/><br /><sub><b>Daniel Schaffer</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=DanielSchaffer" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/commits?author=DanielSchaffer" title="Tests">⚠️</a></td> 244 | <td align="center" valign="top" width="14.28%"><a href="http://damianb.dev"><img src="https://avatars.githubusercontent.com/u/4206989?v=4?s=100" width="100px;" alt="Damian Barabonkov"/><br /><sub><b>Damian Barabonkov</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=DamianB-BitFlipper" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/commits?author=DamianB-BitFlipper" title="Tests">⚠️</a></td> 245 | </tr> 246 | </tbody> 247 | </table> 248 | 249 | <!-- markdownlint-restore --> 250 | 251 | <!-- prettier-ignore-end --> 252 | 253 | <!-- ALL-CONTRIBUTORS-LIST:END --> 254 | 255 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 256 | -------------------------------------------------------------------------------- /django_clone/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/django_clone/__init__.py -------------------------------------------------------------------------------- /django_clone/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_clone project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.0.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | import sys 15 | 16 | from django.utils.translation import gettext_lazy as _ 17 | 18 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 19 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 20 | 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = "hp8o!fg=xg&#dw0bb%!ts+m7z08q#@w48_=o21_0w+!pmkp&w7" 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ROOT_URLCONF = "sample.urls" 32 | 33 | WSGI_APPLICATION = "django_clone.wsgi.application" 34 | 35 | INSTALLED_APPS = [ 36 | "model_clone", 37 | "django.contrib.admin", 38 | "django.contrib.auth", 39 | "django.contrib.contenttypes", 40 | "django.contrib.sessions", 41 | "django.contrib.messages", 42 | "django.contrib.staticfiles", 43 | "django.contrib.humanize", 44 | "sample", 45 | "sample_assignment", 46 | "sample_company", 47 | "sample_driver", 48 | ] 49 | 50 | if sys.version_info >= (3, 6) and "--fix" in sys.argv: 51 | INSTALLED_APPS += ["migration_fixer"] 52 | 53 | 54 | TEMPLATES = [ 55 | { 56 | "BACKEND": "django.template.backends.django.DjangoTemplates", 57 | "DIRS": [], 58 | "APP_DIRS": True, 59 | "OPTIONS": { 60 | "context_processors": [ 61 | "django.template.context_processors.debug", 62 | "django.template.context_processors.request", 63 | "django.template.context_processors.tz", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | 72 | MIDDLEWARE = [ 73 | "django.middleware.security.SecurityMiddleware", 74 | "django.contrib.sessions.middleware.SessionMiddleware", 75 | "django.middleware.common.CommonMiddleware", 76 | "django.middleware.csrf.CsrfViewMiddleware", 77 | "django.contrib.auth.middleware.AuthenticationMiddleware", 78 | "django.contrib.messages.middleware.MessageMiddleware", 79 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 80 | ] 81 | 82 | 83 | # Database 84 | # https://docs.djangoproject.com/en/2.0/ref/settings/#databases 85 | 86 | DATABASES = { 87 | "default": { 88 | "ENGINE": "django.db.backends.sqlite3", 89 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 90 | }, 91 | "replica": { 92 | "ENGINE": "django.db.backends.sqlite3", 93 | "NAME": os.path.join(BASE_DIR, "replica.sqlite3"), 94 | }, 95 | } 96 | 97 | # Password validation 98 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 99 | 100 | AUTH_PASSWORD_VALIDATORS = [ 101 | { 102 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 103 | }, 104 | { 105 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 106 | }, 107 | { 108 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 109 | }, 110 | { 111 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 112 | }, 113 | ] 114 | 115 | 116 | # Internationalization 117 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 118 | 119 | LANGUAGE_CODE = "en-us" 120 | 121 | TIME_ZONE = "UTC" 122 | 123 | USE_I18N = True 124 | 125 | USE_L10N = True 126 | 127 | USE_TZ = True 128 | 129 | 130 | # Static files (CSS, JavaScript, Images) 131 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 132 | 133 | STATIC_URL = "/static/" 134 | 135 | try: 136 | import xmlrunner # noqa 137 | except ImportError: 138 | pass 139 | else: 140 | # https://github.com/xmlrunner/unittest-xml-reporting#django 141 | TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" 142 | TEST_OUTPUT_VERBOSE = 2 143 | TEST_OUTPUT_DESCRIPTIONS = True 144 | TEST_OUTPUT_DIR = "test-reports" 145 | 146 | LANGUAGES = ( 147 | ("en", _("English")), 148 | ("fr", _("French")), 149 | ) 150 | 151 | # Set the default language for your site. 152 | LANGUAGE_CODE = "en" 153 | 154 | # Tell Django where the project's translation files should be. 155 | LOCALE_PATHS = (os.path.join(BASE_DIR, "model_clone", "locale"),) 156 | -------------------------------------------------------------------------------- /django_clone/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_antd project. 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ 6 | """ 7 | 8 | import os 9 | 10 | from django.core.wsgi import get_wsgi_application 11 | 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_clone.settings") 13 | 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/docs/.nojekyll -------------------------------------------------------------------------------- /docs/Duplicate-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/docs/Duplicate-action.png -------------------------------------------------------------------------------- /docs/Duplicate-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/docs/Duplicate-button.png -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | <p align="center"> 2 | <img width="466" alt="4FC889E9-FF59-4E44-9EB6-2AF7DC034C74" src="https://user-images.githubusercontent.com/17484350/215616634-17439a58-7bd8-4e9c-989f-e6bef7c73e48.png"> 3 | </p> 4 | 5 | | Python | Django | Downloads | Code Style | 6 | |:---------:|:-------:|:-----------:|:--------------:| 7 | | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django_clone.svg)](https://pypi.org/project/django-clone) | [![PyPI - Django Version](https://img.shields.io/pypi/djversions/django_clone.svg)](https://docs.djangoproject.com/en/dev/releases/) | [![Downloads](https://static.pepy.tech/badge/django-clone)](https://pepy.tech/project/django-clone) | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) | 8 | 9 | | PyPI | Test | Vulnerabilities | Coverage | Code Quality | Pre-Commit | 10 | |:---------------:|:----:|:---------------:|:--------:|:-------------:|:-------------:| 11 | | [![PyPI version](https://badge.fury.io/py/django-clone.svg)](https://badge.fury.io/py/django-clone) | [![Test](https://github.com/tj-django/django-clone/workflows/Test/badge.svg)](https://github.com/tj-django/django-clone/actions?query=workflow%3ATest) | [![Known Vulnerabilities](https://snyk.io/test/github/tj-django/django-clone/badge.svg?targetFile=requirements.txt)](https://snyk.io/test/github/tj-django/django-clone?targetFile=requirements.txt) | [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/b33dd02dbb034d7fa9886a99f5383ea6)](https://www.codacy.com/gh/tj-django/django-clone?utm_source=github.com\&utm_medium=referral\&utm_content=tj-django/django-clone\&utm_campaign=Badge_Coverage) <br/> [![codecov](https://codecov.io/gh/tj-django/django-clone/branch/main/graph/badge.svg?token=2NE21Oe50Q)](https://codecov.io/gh/tj-django/django-clone)| [![Codacy Badge](https://app.codacy.com/project/badge/Grade/b33dd02dbb034d7fa9886a99f5383ea6)](https://www.codacy.com/gh/tj-django/django-clone?utm_source=github.com\&utm_medium=referral\&utm_content=tj-django/django-clone\&utm_campaign=Badge_Grade) | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/tj-django/django-clone/main.svg)](https://results.pre-commit.ci/latest/github/tj-django/django-clone/main) | 12 | 13 | ## django-clone 14 | 15 | Create copies of a model instance with explicit control on how the instance should be duplicated (limiting fields or related objects copied) with unique field detection. 16 | 17 | This solves the problem introduced by using `instance.pk = None` and `instance.save()` which results in copying more object state than required. 18 | 19 | ## Features 20 | 21 | * 100% test coverage. 22 | * More control over how a model instance should be duplicated 23 | * Multi Database support i.e Create duplicates on one or more databases. 24 | * Restrict fields used for creating a duplicate instance. 25 | * Detects unique fields and naively adds a suffix `copy {count}` to each duplicate instance (for supported fields only). 26 | * Optionally differentiate between a duplicate instance and the original by appending a **copy** suffix to non unique fields (for supported fields only). 27 | 28 | ## Table of Contents 29 | 30 | * [Installation](#installation) 31 | * [pip](#pip) 32 | * [poetry](#poetry) 33 | * [Usage](#usage) 34 | * [Subclassing the `CloneModel`](#subclassing-the-clonemodel) 35 | * [Using the `CloneMixin`](#using-the-clonemixin) 36 | * [Using the `CloneModel`](#using-the-clonemodel) 37 | * [Duplicating a model instance](#duplicating-a-model-instance) 38 | * [Bulk cloning a model](#bulk-cloning-a-model) 39 | * [Creating clones without subclassing `CloneMixin`.](#creating-clones-without-subclassing-clonemixin) 40 | * [CloneMixin attributes](#clonemixin-attributes) 41 | * [Explicit (include only these fields)](#explicit-include-only-these-fields) 42 | * [Implicit (include all except these fields)](#implicit-include-all-except-these-fields) 43 | * [Django Admin](#django-admin) 44 | * [Duplicating Models from the Django Admin view.](#duplicating-models-from-the-django-admin-view) 45 | * [List View](#list-view) 46 | * [Change View](#change-view) 47 | * [CloneModelAdmin class attributes](#clonemodeladmin-class-attributes) 48 | * [Advanced Usage](#advanced-usage) 49 | * [Signals](#signals) 50 | * [`pre_clone_save`, `post_clone_save`](#pre_clone_save-post_clone_save) 51 | * [Multi-database support](#multi-database-support) 52 | * [Compatibility](#compatibility) 53 | * [Running locally](#running-locally) 54 | * [Found a Bug?](#found-a-bug) 55 | * [Contributors ✨](#contributors-) 56 | 57 | ## Installation 58 | 59 | ### pip 60 | 61 | ```bash 62 | pip install django-clone 63 | ``` 64 | 65 | ### poetry 66 | 67 | ```bash 68 | poetry add django-clone 69 | ``` 70 | 71 | ## Usage 72 | 73 | ### Subclassing the `CloneModel` 74 | 75 | ![](https://user-images.githubusercontent.com/17484350/221387430-efd5508a-2597-4320-9750-5a4c56833edb.png) 76 | 77 | ### Using the `CloneMixin` 78 | 79 | ![](https://user-images.githubusercontent.com/17484350/221387397-6ad5475b-6887-4a5f-b6d3-42784f9dfa7c.png) 80 | 81 | ### Using the `CloneModel` 82 | 83 | ![](https://user-images.githubusercontent.com/17484350/221387226-572cedbe-e30e-456d-af75-bcd25edec754.png) 84 | 85 | ### Duplicating a model instance 86 | 87 | ![](https://user-images.githubusercontent.com/17484350/221386600-731a6f45-1704-4834-bcbe-0f57d912faf7.png) 88 | 89 | ### Bulk cloning a model 90 | 91 | ![](https://user-images.githubusercontent.com/17484350/221386555-13978280-35a1-4941-8186-a1c6723a0346.png) 92 | 93 | ### Creating clones without subclassing `CloneMixin`. 94 | 95 | > **NOTE:** :warning: 96 | > 97 | > * This method won't copy over related objects like Many to Many/One to Many relationships. 98 | > * Ensure that required fields skipped from being cloned are passed in using the `attrs` kwargs. 99 | 100 | ![](https://user-images.githubusercontent.com/17484350/221385171-add1a0c3-21fc-4c48-bfe9-4f2014ffe035.png) 101 | 102 | ### CloneMixin attributes 103 | 104 | | Attribute | Description | 105 | |:------------------------------:|:------------:| 106 | | `DUPLICATE_SUFFIX` | Suffix to append to duplicates <br> (NOTE: This requires `USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS` <br> to be enabled and supports string fields). | 107 | `USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS` | Enable appending the `DUPLICATE_SUFFIX` to new cloned instances. | 108 | `UNIQUE_DUPLICATE_SUFFIX` | Suffix to append to unique fields | 109 | `USE_UNIQUE_DUPLICATE_SUFFIX` | Enable appending the `UNIQUE_DUPLICATE_SUFFIX` to new cloned instances. | 110 | `MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS` | The max query attempt while generating unique values for a case of unique conflicts. | 111 | 112 | #### Explicit (include only these fields) 113 | 114 | | Attribute | Description | 115 | |:------------------------------:|:------------:| 116 | | `_clone_fields` | Restrict the list of fields to copy from the instance (By default: Copies all fields excluding auto-created/non editable model fields) | 117 | | `_clone_m2m_fields` | Restricted Many to many fields (i.e Test.tags) | 118 | | `_clone_m2o_or_o2m_fields` | Restricted Many to One/One to Many fields | 119 | | `_clone_o2o_fields` | Restricted One to One fields | 120 | | `_clone_linked_m2m_fields` | Restricted Many to Many fields that should be linked to the new instance | 121 | 122 | #### Implicit (include all except these fields) 123 | 124 | | Attribute | Description | 125 | |:--------------------:|:-----------:| 126 | | `_clone_excluded_fields` | Excluded model fields. | 127 | `_clone_excluded_m2m_fields` | Excluded many to many fields. | 128 | `_clone_excluded_m2o_or_o2m_fields` | Excluded Many to One/One to Many fields. | 129 | `_clone_excluded_o2o_fields` | Excluded one to one fields. | 130 | 131 | > **NOTE:** :warning: 132 | > 133 | > * Ensure to either set `_clone_excluded_*` or `_clone_*`. Using both would raise errors. 134 | 135 | ### Django Admin 136 | 137 | #### Duplicating Models from the Django Admin view. 138 | 139 | ![](https://user-images.githubusercontent.com/17484350/221386874-047989a4-ae4d-4d82-9ef6-2b303001a4c2.png) 140 | 141 | ##### List View 142 | 143 | ![Screenshot](Duplicate-action.png) 144 | 145 | ##### Change View 146 | 147 | ![Screenshot](Duplicate-button.png) 148 | 149 | #### CloneModelAdmin class attributes 150 | 151 | ![](https://user-images.githubusercontent.com/17484350/221387085-e0ca31ee-8c4c-40d9-9ce6-44ff5e6814ff.png) 152 | 153 | > **NOTE:** :warning: 154 | > 155 | > * Ensure that `model_clone` is placed before `django.contrib.admin` 156 | 157 | ```python 158 | INSTALLED_APPS = [ 159 | 'model_clone', 160 | 'django.contrib.admin', 161 | '...', 162 | ] 163 | ``` 164 | 165 | ## Advanced Usage 166 | 167 | ### Signals 168 | 169 | #### `pre_clone_save`, `post_clone_save` 170 | 171 | ![](https://user-images.githubusercontent.com/17484350/221387120-b5219cdb-9f74-4751-b593-2c68db9fd0e0.png) 172 | 173 | ### Multi-database support 174 | 175 | ![](https://user-images.githubusercontent.com/17484350/221385217-3a123080-b247-4ef0-b876-e75db1518c92.png) 176 | 177 | ## Compatibility 178 | 179 | | Python | Supported version | 180 | |--------------|--------------------| 181 | | Python2.x | `<=2.5.3` | 182 | | Python3.5 | `<=2.9.6` | 183 | | Python3.6+ | `<=5.3.3` | 184 | | Python3.7+ | All versions | 185 | 186 | | Django | Supported version | 187 | |--------------|---------------------| 188 | | 1.11 | `<=2.7.2` | 189 | | 2.x | All versions | 190 | | 3.x | All versions | 191 | | 4.x | All versions | 192 | 193 | ## Running locally 194 | 195 | ```shell 196 | $ git clone git@github.com:tj-django/django-clone.git 197 | $ make default-user 198 | $ make run 199 | ``` 200 | 201 | Spins up a django server running the demo app. 202 | 203 | Visit http://127.0.0.1:8000 204 | 205 | ## Found a Bug? 206 | 207 | To file a bug or submit a patch, please head over to [django-clone on github](https://github.com/tj-django/django-clone/issues). 208 | 209 | If you feel generous and want to show some extra appreciation: 210 | 211 | Support me with a :star: 212 | 213 | [![Buy me a coffee][buymeacoffee-shield]][buymeacoffee] 214 | 215 | [buymeacoffee]: https://www.buymeacoffee.com/jackton1 216 | 217 | [buymeacoffee-shield]: https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png 218 | 219 | ## Contributors ✨ 220 | 221 | Thanks goes to these wonderful people: 222 | 223 | <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section --> 224 | 225 | <!-- prettier-ignore-start --> 226 | 227 | <!-- markdownlint-disable --> 228 | 229 | <table> 230 | <tbody> 231 | <tr> 232 | <td align="center" valign="top" width="14.28%"><a href="http://gerritneven.nl"><img src="https://avatars1.githubusercontent.com/u/2500973?v=4?s=100" width="100px;" alt="Gerben Neven"/><br /><sub><b>Gerben Neven</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/issues?q=author%3Agerbyzation" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=gerbyzation" title="Tests">⚠️</a> <a href="https://github.com/tj-django/django-clone/commits?author=gerbyzation" title="Code">💻</a></td> 233 | <td align="center" valign="top" width="14.28%"><a href="http://sebastian-kindt.com"><img src="https://avatars1.githubusercontent.com/u/2536081?v=4?s=100" width="100px;" alt="Sebastian Kapunkt"/><br /><sub><b>Sebastian Kapunkt</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=SebastianKapunkt" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/issues?q=author%3ASebastianKapunkt" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=SebastianKapunkt" title="Tests">⚠️</a></td> 234 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/andresp99999"><img src="https://avatars0.githubusercontent.com/u/1036725?v=4?s=100" width="100px;" alt="Andrés Portillo"/><br /><sub><b>Andrés Portillo</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/issues?q=author%3Aandresp99999" title="Bug reports">🐛</a></td> 235 | <td align="center" valign="top" width="14.28%"><a href="https://renovate.whitesourcesoftware.com"><img src="https://avatars0.githubusercontent.com/u/25180681?v=4?s=100" width="100px;" alt="WhiteSource Renovate"/><br /><sub><b>WhiteSource Renovate</b></sub></a><br /><a href="#maintenance-renovate-bot" title="Maintenance">🚧</a></td> 236 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/yuekui"><img src="https://avatars2.githubusercontent.com/u/2561450?v=4?s=100" width="100px;" alt="Yuekui"/><br /><sub><b>Yuekui</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=yuekui" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/issues?q=author%3Ayuekui" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=yuekui" title="Tests">⚠️</a> <a href="https://github.com/tj-django/django-clone/commits?author=yuekui" title="Documentation">📖</a> <a href="#maintenance-yuekui" title="Maintenance">🚧</a></td> 237 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/diesieben07"><img src="https://avatars.githubusercontent.com/u/1915984?v=4?s=100" width="100px;" alt="Take Weiland"/><br /><sub><b>Take Weiland</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=diesieben07" title="Tests">⚠️</a> <a href="https://github.com/tj-django/django-clone/issues?q=author%3Adiesieben07" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=diesieben07" title="Code">💻</a></td> 238 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/ptrck"><img src="https://avatars.githubusercontent.com/u/1259703?v=4?s=100" width="100px;" alt="Patrick"/><br /><sub><b>Patrick</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/issues?q=author%3Aptrck" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=ptrck" title="Code">💻</a></td> 239 | </tr> 240 | <tr> 241 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/Akollek"><img src="https://avatars.githubusercontent.com/u/5873158?v=4?s=100" width="100px;" alt="Amiel Kollek"/><br /><sub><b>Amiel Kollek</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=Akollek" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/issues?q=author%3AAkollek" title="Bug reports">🐛</a> <a href="https://github.com/tj-django/django-clone/commits?author=Akollek" title="Tests">⚠️</a></td> 242 | <td align="center" valign="top" width="14.28%"><a href="https://erictheise.com/"><img src="https://avatars.githubusercontent.com/u/317680?v=4?s=100" width="100px;" alt="Eric Theise"/><br /><sub><b>Eric Theise</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=erictheise" title="Documentation">📖</a></td> 243 | <td align="center" valign="top" width="14.28%"><a href="https://github.com/DanielSchaffer"><img src="https://avatars.githubusercontent.com/u/334487?v=4?s=100" width="100px;" alt="Daniel Schaffer"/><br /><sub><b>Daniel Schaffer</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=DanielSchaffer" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/commits?author=DanielSchaffer" title="Tests">⚠️</a></td> 244 | <td align="center" valign="top" width="14.28%"><a href="http://damianb.dev"><img src="https://avatars.githubusercontent.com/u/4206989?v=4?s=100" width="100px;" alt="Damian Barabonkov"/><br /><sub><b>Damian Barabonkov</b></sub></a><br /><a href="https://github.com/tj-django/django-clone/commits?author=DamianB-BitFlipper" title="Code">💻</a> <a href="https://github.com/tj-django/django-clone/commits?author=DamianB-BitFlipper" title="Tests">⚠️</a></td> 245 | </tr> 246 | </tbody> 247 | </table> 248 | 249 | <!-- markdownlint-restore --> 250 | 251 | <!-- prettier-ignore-end --> 252 | 253 | <!-- ALL-CONTRIBUTORS-LIST:END --> 254 | 255 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 256 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | * [Installation](#installation) 2 | * [Usage](#usage) 3 | * [Subclassing the `CloneModel`](#subclassing-the-clonemodel) 4 | * [Using the `CloneMixin`](#using-the-clonemixin) 5 | * [Duplicating a model instance](#duplicating-a-model-instance) 6 | * [Bulk cloning a model](#bulk-cloning-a-model) 7 | * [Creating clones without subclassing `CloneMixin`.](#creating-clones-without-subclassing-clonemixin) 8 | * [CloneMixin attributes](#clonemixin-attributes) 9 | * [Explicit (include only these fields)](#explicit-include-only-these-fields) 10 | * [Implicit (include all except these fields)](#implicit-include-all-except-these-fields) 11 | * [Django Admin](#django-admin) 12 | * [Duplicating Models from the Django Admin view.](#duplicating-models-from-the-django-admin-view) 13 | * [List View](#list-view) 14 | * [Change View](#change-view) 15 | * [CloneModelAdmin class attributes](#clonemodeladmin-class-attributes) 16 | * [Advanced Usage](#advanced-usage) 17 | * [Signals](#signals) 18 | * [pre\_clone\_save, post\_clone\_save](#pre_clone_save-post_clone_save) 19 | * [Clone Many to Many fields](#clone-many-to-many-fields) 20 | * [Using the `CloneModel`](#using-the-clonemodel) 21 | * [Using the `CloneMixin`](#using-the-clonemixin-1) 22 | * [Multi database support](#multi-database-support) 23 | * [Compatibility](#compatibility) 24 | * [Running locally](#running-locally) 25 | * [Found a Bug?](#found-a-bug) 26 | * [Contributors ✨](#contributors-) 27 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8"> 5 | <title>django-clone 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
Please wait...
15 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_clone.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | raise ImportError( 11 | "Couldn't import Django. Are you sure it's installed and " 12 | "available on your PYTHONPATH environment variable? Did you " 13 | "forget to activate a virtual environment?" 14 | ) 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /model_clone/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for django-clone.""" 2 | 3 | __author__ = """Tonye Jack""" 4 | __email__ = "jtonye@ymail.com" 5 | __version__ = "5.3.3" 6 | 7 | from model_clone.admin import CloneModelAdmin, CloneModelAdminMixin 8 | from model_clone.mixin import CloneMixin 9 | from model_clone.utils import create_copy_of_instance 10 | 11 | __all__ = [ 12 | "CloneMixin", 13 | "CloneModelAdmin", 14 | "CloneModelAdminMixin", 15 | "create_copy_of_instance", 16 | ] 17 | -------------------------------------------------------------------------------- /model_clone/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import ModelAdmin 2 | from django.contrib.admin.exceptions import DisallowedModelAdminToField 3 | from django.contrib.admin.options import TO_FIELD_VAR 4 | from django.contrib.admin.utils import unquote 5 | from django.http import HttpResponseRedirect 6 | from django.urls import reverse 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from model_clone.mixin import CloneMixin 10 | 11 | 12 | class CloneModelAdminMixin(object): 13 | """Mixin to handle duplication of models.""" 14 | 15 | include_duplicate_action = True 16 | include_duplicate_object_link = True 17 | 18 | def change_view(self, request, object_id, form_url="", extra_context=None): 19 | extra_context = extra_context or {} 20 | extra_context["include_duplicate_object_link"] = ( 21 | self.include_duplicate_object_link 22 | ) 23 | if self.include_duplicate_object_link: 24 | to_field = request.POST.get(TO_FIELD_VAR, request.GET.get(TO_FIELD_VAR)) 25 | if to_field and not self.to_field_allowed(request, to_field): 26 | raise DisallowedModelAdminToField( 27 | "The field %s cannot be referenced." % to_field 28 | ) 29 | 30 | obj = self.get_object(request, unquote(object_id), to_field) 31 | 32 | if obj is not None and request.GET.get("duplicate") == "true": 33 | clone = obj.make_clone() 34 | clone.save() 35 | self.message_user( 36 | request, 37 | _("Duplication successful, redirected to cloned: {}").format(clone), 38 | ) 39 | cloned_admin_url = reverse( 40 | "admin:{0}_{1}_change".format( 41 | clone._meta.app_label, clone._meta.model_name 42 | ), 43 | args=(clone.pk,), 44 | ) 45 | return HttpResponseRedirect(cloned_admin_url) 46 | 47 | return super().changeform_view(request, object_id, form_url, extra_context) 48 | 49 | def make_clone(self, request, queryset): 50 | clone_obj_ids = [] 51 | 52 | for obj in queryset: 53 | clone = obj.make_clone() 54 | clone.save() 55 | clone_obj_ids.append(str(clone.pk)) 56 | 57 | if clone_obj_ids: 58 | self.message_user( 59 | request, 60 | _("Successfully created: {} new duplicates").format(len(clone_obj_ids)), 61 | ) 62 | 63 | make_clone.short_description = _("Duplicate selected %(verbose_name_plural)s") 64 | 65 | def _get_base_actions(self): 66 | """Return the list of actions, prior to any request-based filtering.""" 67 | actions = list(super()._get_base_actions()) 68 | # Add the make clone action 69 | if self.include_duplicate_action and issubclass(self.model, CloneMixin): 70 | actions.extend(self.get_action(action) for action in ["make_clone"]) 71 | 72 | return actions 73 | 74 | 75 | class CloneModelAdmin(CloneModelAdminMixin, ModelAdmin): 76 | """Clone model admin view.""" 77 | -------------------------------------------------------------------------------- /model_clone/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ModelCloneConfig(AppConfig): 5 | name = "model_clone" 6 | -------------------------------------------------------------------------------- /model_clone/locale/en_US/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-06-24 11:44+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Tonye Jack \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: model_clone/admin.py:37 21 | msgid "Duplication successful, redirected to cloned: {}" 22 | msgstr "" 23 | 24 | #: model_clone/admin.py:60 25 | msgid "Successfully created: {} new duplicates" 26 | msgstr "" 27 | 28 | #: model_clone/admin.py:63 29 | #, python-format 30 | msgid "Duplicate selected %(verbose_name_plural)s" 31 | msgstr "" 32 | 33 | #: model_clone/templates/admin/change_form_object_tools.html:5 34 | #: model_clone/templates/clone/change_form.html:7 35 | msgid "Duplicate" 36 | msgstr "" 37 | -------------------------------------------------------------------------------- /model_clone/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-06-24 11:44+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Tonye Jack \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: model_clone/admin.py:37 22 | msgid "Duplication successful, redirected to cloned: {}" 23 | msgstr "" 24 | 25 | #: model_clone/admin.py:60 26 | msgid "Successfully created: {} new duplicates" 27 | msgstr "" 28 | 29 | #: model_clone/admin.py:63 30 | #, python-format 31 | msgid "Duplicate selected %(verbose_name_plural)s" 32 | msgstr "" 33 | 34 | #: model_clone/templates/admin/change_form_object_tools.html:5 35 | #: model_clone/templates/clone/change_form.html:7 36 | msgid "Duplicate" 37 | msgstr "" 38 | -------------------------------------------------------------------------------- /model_clone/mixin.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from itertools import repeat 3 | from typing import List # noqa 4 | 5 | from conditional import conditional 6 | from django.core.checks import Error 7 | from django.core.exceptions import ValidationError 8 | from django.db import IntegrityError, connections, models, transaction 9 | from django.db.models import SlugField 10 | from django.utils.text import slugify 11 | 12 | from model_clone.apps import ModelCloneConfig 13 | from model_clone.signals import post_clone_save, pre_clone_save 14 | from model_clone.utils import ( 15 | clean_value, 16 | context_mutable_attribute, 17 | get_fields_and_unique_fields_from_cls, 18 | get_unique_default, 19 | get_unique_value, 20 | get_value, 21 | transaction_autocommit, 22 | ) 23 | 24 | 25 | class CloneMixin(object): 26 | """CloneMixin mixin to duplicate an object using the model's class. 27 | 28 | :param _clone_fields: Restricted List of fields to copy from the instance. 29 | :type _clone_fields: collections.Iterable 30 | :param _clone_m2m_fields: Many to many fields (Example: TestModel.tags). 31 | :type _clone_m2m_fields: collections.Iterable 32 | :param _clone_m2o_or_o2m_fields: Many to one/One to many fields. 33 | :type _clone_m2o_or_o2m_fields: collections.Iterable 34 | :param _clone_o2o_fields: One to One fields. 35 | :type _clone_o2o_fields: collections.Iterable 36 | 37 | :param _clone_linked_m2m_fields: List of fields to link to the cloned instance. 38 | :type _clone_linked_m2m_fields: collections.Iterable 39 | 40 | :param _clone_excluded_fields: Excluded model fields. 41 | :type _clone_excluded_fields: collections.Iterable 42 | :param _clone_excluded_m2m_fields: Excluded many to many fields. 43 | :type _clone_excluded_m2m_fields: collections.Iterable 44 | :param _clone_excluded_m2o_or_o2m_fields: Excluded many to many 45 | and one to many fields. 46 | :type _clone_excluded_m2o_or_o2m_fields: collections.Iterable 47 | :param _clone_excluded_o2o_fields: Excluded one to one fields. 48 | :type _clone_excluded_o2o_fields: collections.Iterable 49 | 50 | :Example: 51 | >>> from django.conf import settings 52 | >>> from django.db import models 53 | >>> 54 | >>> # Using explicit fields 55 | >>> 56 | >>> class TestModel1(CloneMixin, models.Model): 57 | >>> field_1 = models.CharField(max_length=200) 58 | >>> tags = models.ManyToManyField(Tags) 59 | >>> audiences = models.ManyToManyField(Audience) 60 | >>> user = models.ForiegnKey( 61 | >>> settings.AUTH_USER_MODEL, 62 | >>> on_delete=models.CASCADE, 63 | >>> ) 64 | >>> 65 | >>> _clone_m2m_fields = ['tags', 'audiences'] 66 | >>> _clone_m2o_or_o2m_fields = ['user'] 67 | >>> ... 68 | >>> 69 | >>> # Using implicit all except fields. 70 | >>> 71 | >>> class TestModel2(CloneMixin, models.Model): 72 | >>> field_1 = models.CharField(max_length=200) 73 | >>> tags = models.ManyToManyField(Tags) 74 | >>> audiences = models.ManyToManyField(Audience) 75 | >>> user = models.ForiegnKey( 76 | >>> settings.AUTH_USER_MODEL, 77 | >>> on_delete=models.CASCADE, 78 | >>> null=True, 79 | >>> ) 80 | 81 | >>> # Clones any other m2m field excluding "audiences". 82 | >>> _clone_excluded_m2m_fields = ['audiences'] 83 | >>> # Clones all other many to one or one to many fields excluding "user". 84 | >>> _clone_excluded_m2o_or_o2m_fields = ['user'] 85 | >>> ... 86 | """ 87 | 88 | # Included fields 89 | _clone_fields = [] # type: List[str] 90 | _clone_m2m_fields = [] # type: List[str] 91 | _clone_m2o_or_o2m_fields = [] # type: List[str] 92 | _clone_o2o_fields = [] # type: List[str] 93 | 94 | # Included / linked (not copied) fields 95 | _clone_linked_m2m_fields = [] # type: List[str] 96 | 97 | # Excluded fields 98 | _clone_excluded_fields = [] # type: List[str] 99 | _clone_excluded_m2m_fields = [] # type: List[str] 100 | _clone_excluded_m2o_or_o2m_fields = [] # type: List[str] 101 | _clone_excluded_o2o_fields = [] # type: List[str] 102 | 103 | DUPLICATE_SUFFIX = "copy" # type: str 104 | USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS = False # type: bool 105 | UNIQUE_DUPLICATE_SUFFIX = "copy" # type: str 106 | USE_UNIQUE_DUPLICATE_SUFFIX = True # type: bool 107 | MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS = 100 # type: int 108 | 109 | @classmethod 110 | def check(cls, **kwargs): # pragma: no cover 111 | errors = super(CloneMixin, cls).check(**kwargs) 112 | 113 | if cls.USE_UNIQUE_DUPLICATE_SUFFIX and not cls.UNIQUE_DUPLICATE_SUFFIX: 114 | errors.append( 115 | Error( 116 | "UNIQUE_DUPLICATE_SUFFIX is required.", 117 | hint=( 118 | "Please provide UNIQUE_DUPLICATE_SUFFIX" 119 | f" for {cls.__name__} or set USE_UNIQUE_DUPLICATE_SUFFIX=False" 120 | ), 121 | obj=cls, 122 | id=f"{ModelCloneConfig.name}.E001", 123 | ) 124 | ) 125 | 126 | if cls.USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS and not cls.DUPLICATE_SUFFIX: 127 | errors.append( 128 | Error( 129 | "UNIQUE_DUPLICATE_SUFFIX is required.", 130 | hint=( 131 | f"Please provide DUPLICATE_SUFFIX for {cls.__name__} " 132 | "or set USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS=False" 133 | ), 134 | obj=cls, 135 | id=f"{ModelCloneConfig.name}.E001", 136 | ) 137 | ) 138 | 139 | if all([cls._clone_fields, cls._clone_excluded_fields]): 140 | errors.append( 141 | Error( 142 | "Conflicting configuration.", 143 | hint=( 144 | 'Please provide either "_clone_fields"' 145 | f' or "_clone_excluded_fields" for model {cls.__name__}' 146 | ), 147 | obj=cls, 148 | id=f"{ModelCloneConfig.name}.E002", 149 | ) 150 | ) 151 | 152 | if all([cls._clone_m2m_fields, cls._clone_excluded_m2m_fields]): 153 | errors.append( 154 | Error( 155 | "Conflicting configuration.", 156 | hint=( 157 | 'Please provide either "_clone_m2m_fields"' 158 | f' or "_clone_excluded_m2m_fields" for model {cls.__name__}' 159 | ), 160 | obj=cls, 161 | id=f"{ModelCloneConfig.name}.E002", 162 | ) 163 | ) 164 | 165 | if all( 166 | [ 167 | cls._clone_m2o_or_o2m_fields, 168 | cls._clone_excluded_m2o_or_o2m_fields, 169 | ] 170 | ): 171 | errors.append( 172 | Error( 173 | "Conflicting configuration.", 174 | hint=( 175 | 'Please provide either "_clone_m2o_or_o2m_fields" or ' 176 | f'"_clone_excluded_m2o_or_o2m_fields" for model {cls.__name__}' 177 | ), 178 | obj=cls, 179 | id=f"{ModelCloneConfig.name}.E002", 180 | ) 181 | ) 182 | 183 | if all([cls._clone_o2o_fields, cls._clone_excluded_o2o_fields]): 184 | errors.append( 185 | Error( 186 | "Conflicting configuration.", 187 | hint=( 188 | 'Please provide either "_clone_o2o_fields" or ' 189 | f'"_clone_excluded_o2o_fields" for model {cls.__name__}' 190 | ), 191 | obj=cls, 192 | id=f"{ModelCloneConfig.name}.E002", 193 | ) 194 | ) 195 | 196 | errors.extend(cls.__check_has_invalid_linked_m2m_fields()) 197 | 198 | return errors 199 | 200 | @classmethod 201 | def __check_has_invalid_linked_m2m_fields(cls): 202 | errors = [] 203 | 204 | for field_name in cls._clone_linked_m2m_fields: 205 | field = cls._meta.get_field(field_name) 206 | through_field = getattr(field, "field", getattr(field, "remote_field")) 207 | through_model = through_field.through 208 | if not through_model._meta.auto_created: 209 | errors.append( 210 | Error( 211 | f"Invalid configuration for _clone_linked_m2m_fields: {field_name}", 212 | hint=( 213 | 'Use "_clone_m2m_fields" instead of "_clone_linked_m2m_fields"' 214 | f" for m2m fields that are not auto-created for model {cls.__name__}" 215 | ), 216 | obj=cls, 217 | id=f"{ModelCloneConfig.name}.E003", 218 | ) 219 | ) 220 | 221 | if field_name in cls._clone_excluded_m2m_fields: 222 | errors.append( 223 | Error( 224 | f"Invalid configuration for _clone_excluded_m2m_fields: {field_name}", 225 | hint=( 226 | "Fields that are linked with _clone_linked_m2m_fields " 227 | f"cannot be excluded in _clone_excluded_m2m_fields for model " 228 | f"{cls.__name__}" 229 | ), 230 | obj=cls, 231 | id=f"{ModelCloneConfig.name}.E002", 232 | ) 233 | ) 234 | 235 | if field_name in cls._clone_m2m_fields: 236 | errors.append( 237 | Error( 238 | f"Invalid configuration for _clone_m2m_fields: {field_name}", 239 | hint=( 240 | "Fields that are linked with _clone_linked_m2m_fields " 241 | f"cannot be included in _clone_m2m_fields for model {cls.__name__}" 242 | ), 243 | obj=cls, 244 | id=f"{ModelCloneConfig.name}.E002", 245 | ) 246 | ) 247 | 248 | return errors 249 | 250 | @transaction.atomic 251 | def make_clone(self, attrs=None, sub_clone=False, using=None, parent=None): 252 | """Creates a clone of the django model instance. 253 | 254 | :param attrs: Dictionary of attributes to be replaced on the cloned object. 255 | :type attrs: dict 256 | :param sub_clone: Internal boolean used to detect cloning sub objects. 257 | :type sub_clone: bool 258 | :rtype: :obj:`django.db.models.Model` 259 | :param using: The database alias used to save the created instances. 260 | :type using: str 261 | :return: The model instance that has been cloned. 262 | :param parent: The parent object that is being cloned. 263 | :type parent: :obj:`django.db.models.Model` 264 | """ 265 | using = using or self._state.db or self.__class__._default_manager.db 266 | attrs = attrs or {} 267 | if not self.pk: 268 | raise ValidationError( 269 | "{}: Instance must be saved before it can be cloned.".format( 270 | self.__class__.__name__ 271 | ) 272 | ) 273 | if sub_clone: 274 | duplicate = self # pragma: no cover 275 | duplicate.pk = None # pragma: no cover 276 | else: 277 | duplicate = self._create_copy_of_instance(self, using=using, parent=parent) 278 | 279 | for name, value in attrs.items(): 280 | setattr(duplicate, name, value) 281 | 282 | duplicate = self.__duplicate_m2o_fields(duplicate, using=using) 283 | 284 | pre_clone_save.send(sender=self.__class__, instance=duplicate) 285 | 286 | duplicate.save(using=using) 287 | 288 | duplicate = self.__duplicate_o2o_fields(duplicate, using=using) 289 | duplicate = self.__duplicate_o2m_fields(duplicate, using=using) 290 | duplicate = self.__duplicate_m2m_fields(duplicate, using=using) 291 | duplicate = self.__duplicate_linked_m2m_fields(duplicate) 292 | 293 | post_clone_save.send(sender=self.__class__, instance=duplicate) 294 | 295 | return duplicate 296 | 297 | def bulk_clone( 298 | self, count, attrs=None, batch_size=None, using=None, auto_commit=False 299 | ): 300 | using = using or self._state.db or self.__class__._default_manager.db 301 | ops = connections[using].ops 302 | objs = range(count) 303 | clones = [] 304 | batch_size = batch_size or max(ops.bulk_batch_size([], list(objs)), 1) 305 | 306 | with conditional( 307 | auto_commit, 308 | transaction_autocommit(using=using), 309 | ): 310 | # If count exceeds the MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS 311 | with conditional( 312 | self.MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS < count, 313 | context_mutable_attribute( 314 | self, 315 | "MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS", 316 | count, 317 | ), 318 | ): 319 | if not self.MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS >= count: 320 | raise AssertionError( 321 | "An Unknown error has occurred: Expected ({}) >= ({})".format( 322 | self.MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS, count 323 | ), 324 | ) 325 | clones = list(repeat(self.make_clone(attrs=attrs), batch_size)) 326 | 327 | return clones 328 | 329 | @staticmethod 330 | def _create_copy_of_instance( 331 | instance, using=None, force=False, sub_clone=False, parent=None 332 | ): 333 | """Create a copy of a model instance. 334 | 335 | :param instance: The instance to be duplicated. 336 | :type instance: `django.db.models.Model` 337 | :param using: The database alias used to save the created instances. 338 | :type using: str 339 | :param force: Flag to skip using the current model clone declared attributes. 340 | :type force: bool 341 | :param sub_clone: Flag to skip cloning one to one fields for sub clones. 342 | :type sub_clone: bool 343 | :return: A new transient instance. 344 | :rtype: `django.db.models.Model` 345 | :param parent: The parent object that is being cloned. 346 | :type parent: :obj:`django.db.models.Model` 347 | """ 348 | cls = instance.__class__ 349 | clone_fields = getattr(cls, "_clone_fields", CloneMixin._clone_fields) 350 | clone_excluded_fields = getattr( 351 | cls, "_clone_excluded_fields", CloneMixin._clone_excluded_fields 352 | ) 353 | clone_o2o_fields = getattr( 354 | cls, "_clone_o2o_fields", CloneMixin._clone_o2o_fields 355 | ) 356 | clone_excluded_o2o_fields = getattr( 357 | cls, "_clone_excluded_o2o_fields", CloneMixin._clone_excluded_o2o_fields 358 | ) 359 | unique_duplicate_suffix = getattr( 360 | cls, "UNIQUE_DUPLICATE_SUFFIX", CloneMixin.UNIQUE_DUPLICATE_SUFFIX 361 | ) 362 | use_unique_duplicate_suffix = getattr( 363 | cls, 364 | "USE_UNIQUE_DUPLICATE_SUFFIX", 365 | CloneMixin.USE_UNIQUE_DUPLICATE_SUFFIX, 366 | ) 367 | max_unique_duplicate_query_attempts = getattr( 368 | cls, 369 | "MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS", 370 | CloneMixin.MAX_UNIQUE_DUPLICATE_QUERY_ATTEMPTS, 371 | ) 372 | duplicate_suffix = getattr( 373 | cls, 374 | "DUPLICATE_SUFFIX", 375 | CloneMixin.DUPLICATE_SUFFIX, 376 | ) 377 | use_duplicate_suffix_for_non_unique_fields = getattr( 378 | cls, 379 | "USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS", 380 | CloneMixin.USE_DUPLICATE_SUFFIX_FOR_NON_UNIQUE_FIELDS, 381 | ) 382 | 383 | fields, unique_fields = get_fields_and_unique_fields_from_cls( 384 | model=cls, 385 | force=force, 386 | clone_fields=clone_fields, 387 | clone_excluded_fields=clone_excluded_fields, 388 | clone_o2o_fields=clone_o2o_fields, 389 | clone_excluded_o2o_fields=clone_excluded_o2o_fields, 390 | ) 391 | 392 | new_instance = cls() 393 | 394 | for f in fields: 395 | if not f.editable: 396 | f.pre_save(new_instance, add=True) 397 | continue 398 | 399 | value = f.value_from_object(instance) 400 | 401 | if all( 402 | [ 403 | not f.auto_created, 404 | f.concrete, 405 | f.editable, 406 | f.name in unique_fields, 407 | ] 408 | ): 409 | # Do not try to get unique value for enum type field 410 | if all( 411 | [ 412 | isinstance(f, (models.CharField, models.TextField)), 413 | not f.choices, 414 | isinstance(value, str), 415 | ] 416 | ): 417 | value = clean_value(value, unique_duplicate_suffix) 418 | 419 | if f.has_default(): 420 | value = f.get_default() 421 | 422 | if not callable(f.default) and isinstance(value, str): 423 | value = get_unique_default( 424 | model=cls, 425 | fname=f.attname, 426 | value=value, 427 | transform=( 428 | slugify if isinstance(f, SlugField) else str 429 | ), 430 | suffix=unique_duplicate_suffix, 431 | max_length=f.max_length, 432 | max_attempts=max_unique_duplicate_query_attempts, 433 | ) 434 | 435 | elif use_unique_duplicate_suffix and isinstance(value, str): 436 | value = get_unique_value( 437 | model=cls, 438 | fname=f.attname, 439 | value=value, 440 | transform=(slugify if isinstance(f, SlugField) else str), 441 | suffix=unique_duplicate_suffix, 442 | max_length=f.max_length, 443 | max_attempts=max_unique_duplicate_query_attempts, 444 | using=using, 445 | ) 446 | 447 | elif isinstance(f, models.OneToOneField) and not sub_clone: 448 | if parent is not None: 449 | value = parent.pk 450 | else: 451 | sub_instance = ( 452 | getattr(instance, f.name, None) or f.get_default() 453 | ) 454 | 455 | if sub_instance is not None: 456 | sub_instance = CloneMixin._create_copy_of_instance( 457 | sub_instance, 458 | force=True, 459 | sub_clone=True, 460 | using=using, 461 | ) 462 | sub_instance.save(using=using) 463 | value = sub_instance.pk 464 | elif all( 465 | [ 466 | use_duplicate_suffix_for_non_unique_fields, 467 | f.concrete, 468 | f.editable, 469 | f.name not in unique_fields, 470 | ] 471 | ): 472 | if ( 473 | isinstance(f, (models.CharField, models.TextField)) 474 | and not f.choices 475 | ): 476 | value = get_value( 477 | value=value, 478 | transform=(slugify if isinstance(f, SlugField) else str), 479 | suffix=duplicate_suffix, 480 | max_length=f.max_length, 481 | ) 482 | 483 | setattr(new_instance, f.attname, value) 484 | 485 | return new_instance 486 | 487 | def __duplicate_o2o_fields(self, duplicate, using=None): 488 | """Duplicate one to one fields. 489 | :param duplicate: The transient instance that should be duplicated. 490 | :type duplicate: `django.db.models.Model` 491 | :param using: The database alias used to save the created instances. 492 | :type using: str 493 | :return: The duplicate instance with all the one to one fields duplicated. 494 | """ 495 | for f in self._meta.related_objects: 496 | if f.one_to_one: 497 | if any( 498 | [ 499 | f.name in self._clone_o2o_fields 500 | and f not in self._meta.concrete_fields, 501 | self._clone_excluded_o2o_fields 502 | and f.name not in self._clone_excluded_o2o_fields 503 | and f not in self._meta.concrete_fields, 504 | ] 505 | ): 506 | rel_object = getattr(self, f.name, None) 507 | if rel_object: 508 | if hasattr(rel_object, "make_clone"): 509 | new_rel_object = rel_object.make_clone( 510 | using=using, 511 | parent=duplicate, 512 | ) 513 | else: 514 | new_rel_object = CloneMixin._create_copy_of_instance( 515 | rel_object, 516 | force=True, 517 | using=using, 518 | parent=duplicate, 519 | ) 520 | 521 | setattr(new_rel_object, f.remote_field.name, duplicate) 522 | new_rel_object.save(using=using) 523 | 524 | return duplicate 525 | 526 | def __duplicate_o2m_fields(self, duplicate, using=None): 527 | """Duplicate one to many fields. 528 | 529 | :param duplicate: The transient instance that should be duplicated. 530 | :type duplicate: `django.db.models.Model` 531 | :param using: The database alias used to save the created instances. 532 | :type using: str 533 | :return: The duplicate instance with all the transient one to many duplicated instances. 534 | """ 535 | 536 | for f in itertools.chain( 537 | self._meta.related_objects, self._meta.concrete_fields 538 | ): 539 | if f.one_to_many: 540 | if any( 541 | [ 542 | f.get_accessor_name() in self._clone_m2o_or_o2m_fields, 543 | self._clone_excluded_m2o_or_o2m_fields 544 | and f.get_accessor_name() 545 | not in self._clone_excluded_m2o_or_o2m_fields, 546 | ] 547 | ): 548 | for item in getattr(self, f.get_accessor_name()).all(): 549 | if hasattr(item, "make_clone"): 550 | try: 551 | item.make_clone( 552 | attrs={f.remote_field.name: duplicate}, 553 | using=using, 554 | ) 555 | except IntegrityError: 556 | item.make_clone( 557 | attrs={f.remote_field.name: duplicate}, 558 | sub_clone=True, 559 | using=using, 560 | ) 561 | else: 562 | new_item = CloneMixin._create_copy_of_instance( 563 | item, 564 | force=True, 565 | sub_clone=True, 566 | using=using, 567 | ) 568 | setattr(new_item, f.remote_field.name, duplicate) 569 | 570 | new_item.save(using=using) 571 | 572 | return duplicate 573 | 574 | def __duplicate_m2o_fields(self, duplicate, using=None): 575 | """Duplicate many to one fields. 576 | 577 | :param duplicate: The transient instance that should be duplicated. 578 | :type duplicate: `django.db.models.Model` 579 | :param using: The database alias used to save the created instances. 580 | :type using: str 581 | :return: The duplicate instance with all the many to one fields duplicated. 582 | """ 583 | for f in self._meta.concrete_fields: 584 | if f.many_to_one: 585 | if any( 586 | [ 587 | f.name in self._clone_m2o_or_o2m_fields, 588 | self._clone_excluded_m2o_or_o2m_fields 589 | and f.name not in self._clone_excluded_m2o_or_o2m_fields, 590 | ] 591 | ): 592 | item = getattr(self, f.name) 593 | if hasattr(item, "make_clone"): 594 | try: 595 | item_clone = item.make_clone(using=using) 596 | except IntegrityError: 597 | item_clone = item.make_clone(sub_clone=True, using=using) 598 | elif item is None: 599 | item_clone = None 600 | else: 601 | item_clone = CloneMixin._create_copy_of_instance( 602 | item, 603 | force=True, 604 | sub_clone=True, 605 | using=using, 606 | ) 607 | item_clone.save(using=using) 608 | 609 | setattr(duplicate, f.name, item_clone) 610 | 611 | return duplicate 612 | 613 | def __duplicate_m2m_fields(self, duplicate, using=None): 614 | """Duplicate many to many fields. 615 | 616 | :param duplicate: The transient instance that should be duplicated. 617 | :type duplicate: `django.db.models.Model` 618 | :param using: The database alias used to save the created instances. 619 | :type using: str 620 | :return: The duplicate instance with all the many to many fields duplicated. 621 | """ 622 | fields = set() 623 | 624 | for f in self._meta.many_to_many: 625 | if any( 626 | [ 627 | f.name in self._clone_m2m_fields, 628 | self._clone_excluded_m2m_fields 629 | and f.name not in self._clone_excluded_m2m_fields, 630 | ] 631 | ): 632 | fields.add(f) 633 | 634 | for f in self._meta.related_objects: 635 | if f.many_to_many: 636 | if any( 637 | [ 638 | f.get_accessor_name() in self._clone_m2m_fields, 639 | self._clone_excluded_m2m_fields 640 | and f.get_accessor_name() 641 | not in self._clone_excluded_m2m_fields, 642 | ] 643 | ): 644 | fields.add(f) 645 | 646 | # Clone many to many fields 647 | for field in fields: 648 | if hasattr(field, "field"): 649 | # ManyToManyRel 650 | field_name = field.field.m2m_reverse_field_name() 651 | through = field.through 652 | source = getattr(self, field.get_accessor_name()) 653 | destination = getattr(duplicate, field.get_accessor_name()) 654 | else: 655 | through = field.remote_field.through 656 | field_name = field.m2m_field_name() 657 | source = getattr(self, field.attname) 658 | destination = getattr(duplicate, field.attname) 659 | 660 | if all( 661 | [ 662 | through, 663 | not through._meta.auto_created, 664 | ] 665 | ): 666 | objs = through.objects.filter(**{field_name: self.pk}) 667 | for item in objs: 668 | if hasattr(through, "make_clone"): 669 | try: 670 | item.make_clone( 671 | attrs={field_name: duplicate}, 672 | using=using, 673 | ) 674 | except IntegrityError: 675 | item.make_clone( 676 | attrs={field_name: duplicate}, 677 | sub_clone=True, 678 | using=using, 679 | ) 680 | else: 681 | item.pk = None 682 | setattr(item, field_name, duplicate) 683 | item.save(using=using) 684 | else: 685 | items_clone = [] 686 | for item in source.all(): 687 | if hasattr(item, "make_clone"): 688 | try: 689 | item_clone = item.make_clone( 690 | using=using, 691 | ) 692 | except IntegrityError: 693 | item_clone = item.make_clone( 694 | sub_clone=True, 695 | using=using, 696 | ) 697 | else: 698 | item_clone = CloneMixin._create_copy_of_instance( 699 | item, 700 | force=True, 701 | sub_clone=True, 702 | using=using, 703 | ) 704 | 705 | item_clone.save(using=using) 706 | 707 | items_clone.append(item_clone) 708 | 709 | destination.set(items_clone) 710 | 711 | return duplicate 712 | 713 | def __duplicate_linked_m2m_fields(self, duplicate): 714 | """Duplicate many to many fields. 715 | 716 | :param duplicate: The transient instance that should be duplicated. 717 | :type duplicate: `django.db.models.Model` 718 | :return: The duplicate instance objects from all the many-to-many fields duplicated. 719 | """ 720 | 721 | for field in self._meta.many_to_many: 722 | if all( 723 | [ 724 | field.attname not in self._clone_m2m_fields, 725 | field.attname not in self._clone_excluded_m2m_fields, 726 | field.attname in self._clone_linked_m2m_fields, 727 | ] 728 | ): 729 | source = getattr(self, field.attname) 730 | destination = getattr(duplicate, field.attname) 731 | destination.set(list(source.all())) 732 | 733 | return duplicate 734 | -------------------------------------------------------------------------------- /model_clone/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from model_clone.mixin import CloneMixin 4 | 5 | 6 | class CloneModel(CloneMixin, models.Model): 7 | class Meta: 8 | abstract = True 9 | -------------------------------------------------------------------------------- /model_clone/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import ModelSignal 2 | 3 | pre_clone_save = ModelSignal(use_caching=True) 4 | post_clone_save = ModelSignal(use_caching=True) 5 | -------------------------------------------------------------------------------- /model_clone/templates/admin/change_form_object_tools.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form_object_tools.html" %} 2 | {% load i18n %} 3 | {% block object-tools-items %} 4 | {% if include_duplicate_object_link %} 5 |
  • {% trans "Duplicate" %}
  • 6 | {% endif %} 7 | {{ block.super }} 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /model_clone/templates/clone/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load l10n i18n %} 3 | 4 | {% block submit_buttons_bottom %} 5 | {{ block.super }} 6 |
    7 | 8 |
    9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /model_clone/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/model_clone/tests/__init__.py -------------------------------------------------------------------------------- /model_clone/tests/test_clone_signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | from django.utils import timezone 4 | from django.utils.text import slugify 5 | 6 | from sample.models import Book, Edition 7 | 8 | User = get_user_model() 9 | 10 | 11 | class CloneSignalsTestCase(TestCase): 12 | REPLICA_DB_ALIAS = "replica" 13 | databases = { 14 | "default", 15 | "replica", 16 | } 17 | 18 | @classmethod 19 | def setUpTestData(cls): 20 | cls.user = User.objects.create(username="user") 21 | 22 | def test_signals(self): 23 | name = "New Book" 24 | first_published_at = timezone.datetime( 25 | 1970, 1, 1, tzinfo=timezone.get_default_timezone() 26 | ) 27 | book = Book.objects.create( 28 | name=name, 29 | created_by=self.user, 30 | slug=slugify(name), 31 | published_at=first_published_at, 32 | ) 33 | self.assertEqual(book.published_at, first_published_at) 34 | edition = Edition.objects.create(seq=1, book=book) 35 | cloned_edition = edition.make_clone() 36 | self.assertEqual(cloned_edition.seq, 2) 37 | book.refresh_from_db() 38 | self.assertNotEqual(book.published_at, first_published_at) 39 | -------------------------------------------------------------------------------- /model_clone/tests/test_create_copy_of_instance.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.exceptions import ValidationError 3 | from django.db import DEFAULT_DB_ALIAS, IntegrityError 4 | from django.test import TestCase 5 | from django.utils.text import slugify 6 | 7 | from model_clone import create_copy_of_instance 8 | from sample.models import Book, Library 9 | 10 | User = get_user_model() 11 | 12 | 13 | class CreateCopyOfInstanceTestCase(TestCase): 14 | REPLICA_DB_ALIAS = "replica" 15 | databases = { 16 | "default", 17 | "replica", 18 | } 19 | 20 | @classmethod 21 | def setUpTestData(cls): 22 | cls.user1 = User.objects.create(username="user 1") 23 | cls.user2 = User.objects.create(username="user 2") 24 | 25 | def test_cloning_model_with_custom_id(self): 26 | instance = Library.objects.create(name="First library", user=self.user1) 27 | clone = create_copy_of_instance(instance, attrs={"user": self.user2}) 28 | 29 | self.assertNotEqual(instance.pk, clone.pk) 30 | self.assertEqual(clone.user, self.user2) 31 | 32 | def test_cloning_model_with_a_different_db_alias_is_valid(self): 33 | new_user = User(username="new user 1") 34 | new_user.save(using=self.REPLICA_DB_ALIAS) 35 | instance = Library(name="First library", user=self.user1) 36 | instance.save(using=DEFAULT_DB_ALIAS) 37 | clone = create_copy_of_instance( 38 | instance, attrs={"user": new_user}, using=self.REPLICA_DB_ALIAS 39 | ) 40 | 41 | self.assertNotEqual(instance.pk, clone.pk) 42 | self.assertEqual(clone.user, new_user) 43 | self.assertNotEqual(instance._state.db, clone._state.db) 44 | 45 | def test_cloning_unique_fk_field_without_a_fallback_value_is_invalid(self): 46 | name = "New Library" 47 | instance = Library.objects.create(name=name, user=self.user1) 48 | 49 | with self.assertRaises(ValidationError): 50 | create_copy_of_instance(instance) 51 | 52 | def test_cloning_excluded_field_without_a_fallback_value_is_invalid(self): 53 | name = "New Library" 54 | instance = Book.objects.create( 55 | name=name, created_by=self.user1, slug=slugify(name) 56 | ) 57 | 58 | with self.assertRaises(IntegrityError): 59 | create_copy_of_instance( 60 | instance, exclude={"slug"}, attrs={"created_by": self.user2} 61 | ) 62 | 63 | def test_raises_error_when_create_copy_of_instance_uses_an_invalid_attrs_value( 64 | self, 65 | ): 66 | instance = Library.objects.create(name="First library", user=self.user1) 67 | 68 | with self.assertRaises(ValueError): 69 | create_copy_of_instance(instance, attrs="user") 70 | 71 | def test_cloning_an_invalid_object_is_invalid(self): 72 | class InvalidObj: 73 | def __init__(self): 74 | pass 75 | 76 | instance = InvalidObj() 77 | 78 | with self.assertRaises(ValueError): 79 | create_copy_of_instance(instance, attrs={"created_by": self.user2}) 80 | -------------------------------------------------------------------------------- /model_clone/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from model_clone.utils import clean_value 4 | 5 | 6 | class CleanValueTestCase(TestCase): 7 | def test_clean_value_with_single_digit(self): 8 | """Test cleaning value with single digit suffix.""" 9 | result = clean_value("Test Copy 1", "Copy") 10 | self.assertEqual(result, "Test") 11 | 12 | def test_clean_value_with_multi_digit(self): 13 | """Test cleaning value with multi-digit suffix.""" 14 | result = clean_value("Test Copy 10", "Copy") 15 | self.assertEqual(result, "Test") 16 | 17 | result = clean_value("Test Copy 123", "Copy") 18 | self.assertEqual(result, "Test") 19 | 20 | def test_clean_value_with_hyphen_separator(self): 21 | """Test cleaning value with hyphen separator.""" 22 | result = clean_value("test-copy-1", "copy") 23 | self.assertEqual(result, "test") 24 | 25 | result = clean_value("test-copy-42", "copy") 26 | self.assertEqual(result, "test") 27 | 28 | def test_clean_value_case_insensitive(self): 29 | """Test that cleaning is case insensitive.""" 30 | result = clean_value("Test COPY 1", "copy") 31 | self.assertEqual(result, "Test") 32 | 33 | result = clean_value("test copy 1", "COPY") 34 | self.assertEqual(result, "test") 35 | 36 | def test_clean_value_with_regex_special_characters(self): 37 | """Test cleaning value when suffix contains regex special characters.""" 38 | result = clean_value("Test (Copy) 1", "(Copy)") 39 | self.assertEqual(result, "Test") 40 | 41 | result = clean_value("Test Copy+ 2", "Copy+") 42 | self.assertEqual(result, "Test") 43 | 44 | result = clean_value("Test Copy* 5", "Copy*") 45 | self.assertEqual(result, "Test") 46 | 47 | result = clean_value("Test Copy? 3", "Copy?") 48 | self.assertEqual(result, "Test") 49 | 50 | result = clean_value("Test Copy[1] 4", "Copy[1]") 51 | self.assertEqual(result, "Test") 52 | 53 | def test_clean_value_with_dots_and_brackets(self): 54 | """Test cleaning with complex regex characters.""" 55 | result = clean_value("Test Copy.exe 1", "Copy.exe") 56 | self.assertEqual(result, "Test") 57 | 58 | result = clean_value("Test (v2.0) 15", "(v2.0)") 59 | self.assertEqual(result, "Test") 60 | 61 | def test_clean_value_no_match(self): 62 | """Test that value is unchanged when pattern doesn't match.""" 63 | result = clean_value("Test Copy", "Copy") 64 | self.assertEqual(result, "Test Copy") 65 | 66 | result = clean_value("Test Different 1", "Copy") 67 | self.assertEqual(result, "Test Different 1") 68 | 69 | result = clean_value("Test Copy A", "Copy") 70 | self.assertEqual(result, "Test Copy A") 71 | 72 | def test_clean_value_partial_match(self): 73 | """Test that partial matches are not cleaned.""" 74 | result = clean_value("Test Copying 1", "Copy") 75 | self.assertEqual(result, "Test Copying 1") 76 | 77 | result = clean_value("Test Copy Something 1", "Copy") 78 | self.assertEqual(result, "Test Copy Something 1") 79 | 80 | def test_clean_value_middle_of_string(self): 81 | """Test that pattern in middle of string is not cleaned.""" 82 | result = clean_value("Test Copy 1 More", "Copy") 83 | self.assertEqual(result, "Test Copy 1 More") 84 | 85 | def test_clean_value_with_empty_suffix(self): 86 | """Test behavior with empty suffix.""" 87 | result = clean_value("Test 1", "") 88 | self.assertEqual(result, "Test") 89 | 90 | def test_clean_value_with_space_before_suffix(self): 91 | """Test cleaning when there's a space before suffix.""" 92 | result = clean_value("Test Copy 25", "Copy") 93 | self.assertEqual(result, "Test") 94 | 95 | def test_clean_value_with_hyphen_before_suffix(self): 96 | """Test cleaning when there's a hyphen before suffix.""" 97 | result = clean_value("test-copy-99", "copy") 98 | self.assertEqual(result, "test") 99 | 100 | def test_clean_value_zero_digit(self): 101 | """Test cleaning with zero as digit.""" 102 | result = clean_value("Test Copy 0", "Copy") 103 | self.assertEqual(result, "Test") 104 | 105 | def test_clean_value_leading_zeros(self): 106 | """Test cleaning with leading zeros in digits.""" 107 | result = clean_value("Test Copy 001", "Copy") 108 | self.assertEqual(result, "Test") 109 | 110 | result = clean_value("Test Copy 010", "Copy") 111 | self.assertEqual(result, "Test") 112 | 113 | def test_clean_value_complex_example(self): 114 | """Test with a complex real-world example.""" 115 | result = clean_value("my-awesome-slug-copy-42", "copy") 116 | self.assertEqual(result, "my-awesome-slug") 117 | 118 | result = clean_value("Product (v1.0) Copy 123", "(v1.0) Copy") 119 | self.assertEqual(result, "Product") 120 | -------------------------------------------------------------------------------- /model_clone/utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import re 3 | 4 | import six 5 | from django.db import models, transaction 6 | from django.db.transaction import TransactionManagementError 7 | 8 | 9 | def create_copy_of_instance( 10 | instance, attrs=None, exclude=(), save_new=True, using=None 11 | ): 12 | """ 13 | Clone an instance of `django.db.models.Model`. 14 | 15 | :param instance: The model instance to clone. 16 | :type instance: django.db.models.Model 17 | :param exclude: List or set of fields to exclude from unique validation. 18 | :type exclude: list|set 19 | :param save_new: Save the model instance after duplication calling .save(). 20 | :type save_new: bool 21 | :param using: The database alias used to save the created instances. 22 | :type using: str 23 | :param attrs: Kwargs of field and value to set on the duplicated instance. 24 | :type attrs: dict 25 | :return: The new duplicated instance. 26 | :rtype: django.db.models.Model 27 | 28 | :example: 29 | >>> from django.contrib.auth import get_user_model 30 | >>> from sample.models import Book 31 | >>> instance = Book.objects.create(name='The Beautiful Life') 32 | >>> instance.pk 33 | 1 34 | >>> instance.name 35 | "The Beautiful Life" 36 | >>> duplicate = create_copy_of_instance(instance, attrs={'name': 'Duplicate Book 2'}) 37 | >>> duplicate.pk 38 | 2 39 | >>> duplicate.name 40 | "Duplicate Book 2" 41 | """ 42 | 43 | if not isinstance(instance, models.Model): 44 | raise ValueError("Invalid: Expected an instance of django.db.models.Model") 45 | 46 | defaults = {} 47 | attrs = attrs or {} 48 | default_db_alias = instance._state.db or instance.__class__._default_manager.db 49 | using = using or default_db_alias 50 | fields = instance.__class__._meta.concrete_fields 51 | 52 | if not isinstance(attrs, dict): 53 | try: 54 | attrs = dict(attrs) 55 | except (TypeError, ValueError): 56 | raise ValueError( 57 | "Invalid: Expected attrs to be a dict or iterable of key and value tuples." 58 | ) 59 | 60 | for f in fields: 61 | if all( 62 | [ 63 | not f.auto_created, 64 | not f.primary_key, 65 | f.concrete, 66 | f.editable, 67 | f not in instance.__class__._meta.related_objects, 68 | f not in instance.__class__._meta.many_to_many, 69 | ] 70 | ): 71 | # Prevent duplicates 72 | if f.name not in attrs: 73 | defaults[f.attname] = getattr(instance, f.attname, f.get_default()) 74 | 75 | defaults.update(attrs) 76 | 77 | new_obj = instance.__class__(**defaults) 78 | 79 | exclude = set( 80 | [ 81 | f.name 82 | for f in instance._meta.fields 83 | if any( 84 | [ 85 | all([f.name not in defaults, f.attname not in defaults]), 86 | f.has_default(), 87 | f.null, 88 | ] 89 | ) 90 | ] 91 | + list(exclude) 92 | ) 93 | 94 | # Bug with django using full_clean on a different db 95 | if using == default_db_alias: 96 | # Validate the new instance on the same database 97 | new_obj.full_clean(exclude=exclude) 98 | 99 | if save_new: 100 | new_obj.save(using=using) 101 | 102 | return new_obj 103 | 104 | 105 | def unpack_unique_constraints(opts, only_fields=()): 106 | """ 107 | Unpack unique constraint fields. 108 | 109 | :param opts: Model options 110 | :type opts: `django.db.models.options.Options` 111 | :param only_fields: Fields that should be considered. 112 | :type only_fields: `collections.Iterable` 113 | :return: Flat list of fields. 114 | """ 115 | fields = [] 116 | constraints = getattr( 117 | opts, "total_unique_constraints", getattr(opts, "constraints", []) 118 | ) 119 | for constraint in constraints: 120 | fields.extend([f for f in constraint.fields if f in only_fields]) 121 | return fields 122 | 123 | 124 | def unpack_unique_together(opts, only_fields=()): 125 | """ 126 | Unpack unique together fields. 127 | 128 | :param opts: Model options 129 | :type opts: `django.db.models.options.Options` 130 | :param only_fields: Fields that should be considered. 131 | :type only_fields: `collections.Iterable` 132 | :return: Flat list of fields. 133 | """ 134 | fields = [] 135 | for field in opts.unique_together: 136 | fields.extend(list([f for f in field if f in only_fields])) 137 | return fields 138 | 139 | 140 | def clean_value(value, suffix): 141 | """ 142 | Strip out copy suffix from a string value. 143 | 144 | :param value: Current value e.g "Test Copy" or "test-copy" for slug fields. 145 | :type value: `str` 146 | :param suffix: The suffix value to be replaced with an empty string. 147 | :type suffix: `str` 148 | :return: Stripped string without the suffix. 149 | :rtype: `str` 150 | """ 151 | # type: (str, str) -> str 152 | escaped_suffix = re.escape(suffix) 153 | return re.sub(r"([\s-]?){}[\s-]\d+$".format(escaped_suffix), "", value, flags=re.I) 154 | 155 | 156 | @contextlib.contextmanager 157 | def transaction_autocommit(using=None): 158 | """ 159 | Context manager to enable autocommit. 160 | 161 | :param using: The database alias used to save the created instances. 162 | :type using: str 163 | """ 164 | try: 165 | transaction.set_autocommit(True, using=using) 166 | yield 167 | except TransactionManagementError: 168 | raise 169 | 170 | 171 | @contextlib.contextmanager 172 | def context_mutable_attribute(obj, key, value): 173 | """ 174 | Context manager that modifies an obj temporarily. 175 | 176 | :param obj: The object to modify. 177 | :type obj: `object` 178 | :param key: The attribute name to modify. 179 | :type key: `str` 180 | :param value: The value to set on the attribute. 181 | :type value: `object` 182 | """ 183 | attribute_exists = hasattr(obj, key) 184 | default = getattr(obj, key, None) 185 | try: 186 | setattr(obj, key, value) 187 | yield 188 | finally: 189 | if attribute_exists: 190 | setattr(obj, key, default) 191 | else: 192 | delattr(obj, key) # pragma: no cover 193 | 194 | 195 | def get_value(value, suffix, transform, max_length, index=None): 196 | """ 197 | Append a suffix to a string value and pass it directly to a 198 | transformation function. 199 | 200 | :param value: Current value e.g "Test Copy" or "test-copy" for slug fields. 201 | :type value: `str` 202 | :param suffix: The suffix value to be replaced with an empty string. 203 | :type suffix: `str` 204 | :param transform: The transformation function to apply to the value. 205 | :type transform: `callable` 206 | :param max_length: The maximum length of the value. 207 | :type max_length: `int` 208 | :param index: The index of the copy. 209 | :type index: `int` 210 | :return: The transformed value. 211 | """ 212 | if index is None: 213 | duplicate_suffix = " {}".format(suffix.strip()) 214 | else: 215 | duplicate_suffix = " {} {}".format(suffix.strip(), index) 216 | 217 | total_length = len(value + duplicate_suffix) 218 | 219 | if max_length is not None and total_length > max_length: 220 | # Truncate the value to max_length - suffix length. 221 | value = value[: max_length - len(duplicate_suffix)] 222 | 223 | return transform("{}{}".format(value, duplicate_suffix)) 224 | 225 | 226 | def generate_value(value, suffix, transform, max_length, max_attempts): 227 | """ 228 | Given a fixed max attempt generate a unique value. 229 | 230 | :param value: Current value e.g "Test Copy" or "test-copy" for slug fields. 231 | :type value: `str` 232 | :param suffix: The suffix value to be replaced with an empty string. 233 | :type suffix: `str` 234 | :param transform: The transformation function to apply to the value. 235 | :type transform: `callable` 236 | :param max_length: The maximum length of the value. 237 | :type max_length: `int` 238 | :param max_attempts: The maximum number of attempts to generate a unique value. 239 | :type max_attempts: `int` 240 | :return: The unique value. 241 | """ 242 | 243 | for i in range(1, max_attempts): 244 | yield get_value(value, suffix, transform, max_length, i) 245 | 246 | raise StopIteration( 247 | "CloneError: max unique attempts for {} exceeded ({})".format( 248 | value, max_attempts 249 | ) 250 | ) 251 | 252 | 253 | def get_unique_value( 254 | model, 255 | fname, 256 | value="", 257 | transform=lambda v: v, 258 | suffix="", 259 | max_length=None, 260 | max_attempts=100, 261 | using=None, 262 | ): 263 | """ 264 | Generate a unique value using current value and query the model 265 | for existing objects with the new value. 266 | """ 267 | qs = model._default_manager.using(using or model._default_manager.db).all() 268 | 269 | if not qs.filter(**{fname: value}).exists(): 270 | return value 271 | 272 | it = generate_value(value, suffix, transform, max_length, max_attempts) 273 | new = six.next(it) 274 | kwargs = {fname: new} 275 | 276 | while qs.filter(**kwargs).exists(): 277 | new = six.next(it) 278 | kwargs[fname] = new 279 | 280 | return new 281 | 282 | 283 | def get_fields_and_unique_fields_from_cls( 284 | model, 285 | force, 286 | clone_fields, 287 | clone_excluded_fields, 288 | clone_o2o_fields, 289 | clone_excluded_o2o_fields, 290 | ): 291 | """Get a list of all fields and unique fields from a model class. 292 | 293 | Skip the clone_* properties if force is ``True``. 294 | """ 295 | fields = [] 296 | 297 | for f in model._meta.concrete_fields: 298 | valid = False 299 | if not getattr(f, "primary_key", False): 300 | if clone_fields and not force and not getattr(f, "one_to_one", False): 301 | valid = f.name in clone_fields 302 | elif ( 303 | clone_excluded_fields 304 | and not force 305 | and not getattr(f, "one_to_one", False) 306 | ): 307 | valid = f.name not in clone_excluded_fields 308 | elif clone_o2o_fields and not force and getattr(f, "one_to_one", False): 309 | valid = f.name in clone_o2o_fields 310 | elif ( 311 | clone_excluded_o2o_fields 312 | and not force 313 | and getattr(f, "one_to_one", False) 314 | ): 315 | valid = f.name not in clone_excluded_o2o_fields # pragma: no cover 316 | else: 317 | valid = True 318 | 319 | if valid: 320 | fields.append(f) 321 | 322 | unique_field_names = unpack_unique_together( 323 | opts=model._meta, 324 | only_fields=[f.attname for f in fields], 325 | ) 326 | 327 | unique_constraint_field_names = unpack_unique_constraints( 328 | opts=model._meta, 329 | only_fields=[f.attname for f in fields], 330 | ) 331 | 332 | unique_fields = [ 333 | f.name 334 | for f in fields 335 | if not f.auto_created 336 | and ( 337 | f.unique 338 | or f.name in unique_field_names 339 | or f.name in unique_constraint_field_names 340 | ) 341 | ] 342 | 343 | return fields, unique_fields 344 | 345 | 346 | def get_unique_default( 347 | model, 348 | fname, 349 | value, 350 | transform=lambda v: v, 351 | suffix="", 352 | max_length=None, 353 | max_attempts=100, 354 | ): 355 | """Get a unique value using the value and adding a suffix if needed.""" 356 | 357 | qs = model._default_manager.all() 358 | 359 | if not qs.filter(**{fname: value}).exists(): 360 | return value 361 | 362 | it = generate_value( 363 | value, 364 | suffix, 365 | transform, 366 | max_length, 367 | max_attempts, 368 | ) 369 | 370 | new = six.next(it) 371 | kwargs = {fname: new} 372 | 373 | while qs.filter(**kwargs).exists(): 374 | new = six.next(it) 375 | kwargs[fname] = new 376 | 377 | return new 378 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "enabled": true, 6 | "prHourlyLimit": 10, 7 | "prConcurrentLimit": 5, 8 | "rebaseWhen": "behind-base-branch", 9 | "addLabels": [ 10 | "dependencies" 11 | ], 12 | "assignees": [ 13 | "jackton1" 14 | ], 15 | "assignAutomerge": true, 16 | "dependencyDashboard": true, 17 | "dependencyDashboardAutoclose": true, 18 | "lockFileMaintenance": { 19 | "enabled": true, 20 | "automerge": true 21 | }, 22 | "packageRules": [ 23 | { 24 | "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"], 25 | "automerge": true, 26 | "rebaseWhen": "behind-base-branch", 27 | "addLabels": [ 28 | "automerge" 29 | ] 30 | }, 31 | { 32 | "description": "docker images", 33 | "matchLanguages": [ 34 | "docker" 35 | ], 36 | "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest"], 37 | "rebaseWhen": "behind-base-branch", 38 | "addLabels": [ 39 | "merge-when-passing" 40 | ], 41 | "automerge": true 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | asgiref==3.8.1 8 | # via django 9 | conditional==2.0 10 | # via django-clone (setup.py) 11 | django==5.2.2 12 | # via django-clone (setup.py) 13 | six==1.17.0 14 | # via django-clone (setup.py) 15 | sqlparse==0.5.3 16 | # via django 17 | -------------------------------------------------------------------------------- /sample/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "sample.apps.SampleConfig" 2 | -------------------------------------------------------------------------------- /sample/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from model_clone import CloneModelAdmin 4 | from sample.models import Author, Book, Library, Page 5 | 6 | 7 | class PageInline(admin.StackedInline): 8 | model = Page 9 | fields = ["content"] 10 | 11 | 12 | @admin.register(Book) 13 | class BookAdmin(CloneModelAdmin): 14 | pass 15 | 16 | 17 | @admin.register(Author) 18 | class AuthorAdmin(CloneModelAdmin): 19 | list_display = ["first_name", "last_name", "sex", "age"] 20 | 21 | 22 | @admin.register(Library) 23 | class LibraryAdmin(CloneModelAdmin): 24 | model = Library 25 | fields = ["name", "user"] 26 | -------------------------------------------------------------------------------- /sample/apps.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=unused-import 2 | from django.apps import AppConfig 3 | 4 | 5 | class SampleConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "sample" 8 | 9 | def ready(self): 10 | from . import signals # noqa: F401 11 | -------------------------------------------------------------------------------- /sample/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample/management/__init__.py -------------------------------------------------------------------------------- /sample/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample/management/commands/__init__.py -------------------------------------------------------------------------------- /sample/management/commands/create_default_user.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.core.management.base import BaseCommand 5 | 6 | User = get_user_model() 7 | 8 | 9 | class Command(BaseCommand): 10 | def handle(self, *args, **options): 11 | email = os.getenv("ADMIN_EMAIL", "admin@admin.com") 12 | password = os.getenv("ADMIN_PASSWORD", "admin") 13 | 14 | if not User.objects.filter(email=email).exists(): 15 | User.objects.create_superuser( 16 | username=email, 17 | email=email, 18 | first_name="admin", 19 | last_name="admin", 20 | password=password, 21 | ) 22 | self.stdout.write("Created superuser.") 23 | else: 24 | self.stderr.write("User already exists.") 25 | -------------------------------------------------------------------------------- /sample/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2019-09-26 09:58 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Author", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("first_name", models.CharField(max_length=200)), 29 | ("last_name", models.CharField(max_length=200)), 30 | ("age", models.PositiveIntegerField()), 31 | ( 32 | "sex", 33 | models.CharField( 34 | choices=[("F", "Female"), ("M", "Male")], max_length=1 35 | ), 36 | ), 37 | ( 38 | "created_by", 39 | models.ForeignKey( 40 | on_delete=django.db.models.deletion.PROTECT, 41 | to=settings.AUTH_USER_MODEL, 42 | ), 43 | ), 44 | ], 45 | ), 46 | migrations.CreateModel( 47 | name="Book", 48 | fields=[ 49 | ( 50 | "id", 51 | models.AutoField( 52 | auto_created=True, 53 | primary_key=True, 54 | serialize=False, 55 | verbose_name="ID", 56 | ), 57 | ), 58 | ("name", models.CharField(max_length=2000)), 59 | ("slug", models.SlugField(unique=True)), 60 | ( 61 | "authors", 62 | models.ManyToManyField(related_name="books", to="sample.Author"), 63 | ), 64 | ( 65 | "created_by", 66 | models.ForeignKey( 67 | on_delete=django.db.models.deletion.PROTECT, 68 | to=settings.AUTH_USER_MODEL, 69 | ), 70 | ), 71 | ], 72 | ), 73 | ] 74 | -------------------------------------------------------------------------------- /sample/migrations/0002_library.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.7 on 2019-11-04 13:46 2 | import uuid 3 | 4 | import django.db.models.deletion 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | import model_clone.mixin 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("sample", "0001_initial"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Library", 20 | fields=[ 21 | ( 22 | "id", 23 | models.UUIDField( 24 | default=uuid.uuid4, primary_key=True, serialize=False 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=100)), 28 | ( 29 | "user", 30 | models.OneToOneField( 31 | on_delete=django.db.models.deletion.PROTECT, 32 | to=settings.AUTH_USER_MODEL, 33 | ), 34 | ), 35 | ], 36 | bases=(model_clone.mixin.CloneMixin, models.Model), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /sample/migrations/0003_book_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2019-11-21 23:37 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("sample", "0002_library"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="book", 15 | name="created_at", 16 | field=models.DateTimeField( 17 | auto_now_add=True, default=django.utils.timezone.now 18 | ), 19 | preserve_default=False, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /sample/migrations/0004_auto_20191122_0848.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2019-11-22 08:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample", "0003_book_created_at"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="author", 14 | name="first_name", 15 | field=models.CharField(max_length=200, unique=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /sample/migrations/0005_page.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2019-12-03 09:47 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import model_clone.mixin 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("sample", "0004_auto_20191122_0848"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Page", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("content", models.CharField(max_length=20000)), 28 | ( 29 | "book", 30 | models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | to="sample.Book", 33 | ), 34 | ), 35 | ], 36 | options={ 37 | "abstract": False, 38 | }, 39 | bases=(model_clone.mixin.CloneMixin, models.Model), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /sample/migrations/0006_assignment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-16 17:33 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import model_clone.mixin 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("sample_company", "0001_initial"), 12 | ("sample_assignment", "0001_initial"), 13 | ("sample_driver", "0001_initial"), 14 | ("sample", "0005_page"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Assignment", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ("created_at", models.DateTimeField(auto_now_add=True, null=True)), 31 | ("updated_at", models.DateTimeField(auto_now=True, null=True)), 32 | ( 33 | "title", 34 | models.CharField( 35 | blank=True, default="", max_length=100, verbose_name="Job title" 36 | ), 37 | ), 38 | ("assignment_date", models.DateField(blank=True, null=True)), 39 | ( 40 | "assignment_status", 41 | models.CharField( 42 | blank=True, 43 | choices=[(1, "Complete"), (2, "Incomplete")], 44 | default="O", 45 | max_length=2, 46 | verbose_name="Assignment status", 47 | ), 48 | ), 49 | ( 50 | "location", 51 | models.CharField( 52 | max_length=25, default="", verbose_name="Location" 53 | ), 54 | ), 55 | ( 56 | "driver_type", 57 | models.CharField( 58 | choices=[(1, "Commercial"), (2, "Residential")], 59 | max_length=2, 60 | default="", 61 | verbose_name="Driver type", 62 | ), 63 | ), 64 | ( 65 | "car_type", 66 | models.CharField( 67 | choices=[(1, "Large"), (2, "Small")], 68 | max_length=2, 69 | default="", 70 | verbose_name="Car type", 71 | ), 72 | ), 73 | ( 74 | "compensation", 75 | models.DecimalField( 76 | decimal_places=2, 77 | max_digits=5, 78 | null=True, 79 | verbose_name="Compensation", 80 | ), 81 | ), 82 | ( 83 | "hours", 84 | models.IntegerField(verbose_name="Amount of hours"), 85 | ), 86 | ( 87 | "spots_available", 88 | models.IntegerField(null=True, verbose_name="Spots available"), 89 | ), 90 | ( 91 | "description", 92 | models.TextField(blank=True, verbose_name="Assignment description"), 93 | ), 94 | ( 95 | "applied_drivers", 96 | models.ManyToManyField( 97 | blank=True, 98 | related_name="driver_applications", 99 | to="sample_driver.Driver", 100 | verbose_name="Driver applications", 101 | ), 102 | ), 103 | ( 104 | "chosen_drivers", 105 | models.ManyToManyField( 106 | blank=True, 107 | to="sample_driver.Driver", 108 | verbose_name="Chosen drivers", 109 | ), 110 | ), 111 | ( 112 | "company", 113 | models.ForeignKey( 114 | blank=True, 115 | null=True, 116 | on_delete=django.db.models.deletion.CASCADE, 117 | to="sample_company.CompanyDepot", 118 | verbose_name="Company", 119 | ), 120 | ), 121 | ( 122 | "contract", 123 | models.ForeignKey( 124 | blank=True, 125 | null=True, 126 | on_delete=django.db.models.deletion.SET_NULL, 127 | related_name="assignment_contracts", 128 | to="sample_assignment.Contract", 129 | verbose_name="Choose contract", 130 | ), 131 | ), 132 | ], 133 | options={ 134 | "verbose_name": "Assigment", 135 | "verbose_name_plural": "Assignments", 136 | }, 137 | bases=(model_clone.mixin.CloneMixin, models.Model), 138 | ), 139 | ] 140 | -------------------------------------------------------------------------------- /sample/migrations/0007_auto_20200829_0213.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.4 on 2020-08-29 02:13 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample", "0006_assignment"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterUniqueTogether( 13 | name="author", 14 | unique_together={("first_name", "last_name", "sex")}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /sample/migrations/0008_page_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-07 11:58 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("sample", "0007_auto_20200829_0213"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="page", 15 | name="created_at", 16 | field=models.DateTimeField(default=django.utils.timezone.now), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /sample/migrations/0009_auto_20210407_1546.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-07 15:46 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample", "0008_page_created_at"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="library", 14 | options={"verbose_name_plural": "libraries"}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /sample/migrations/0010_furniture_house_room.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-10 13:50 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import model_clone.mixin 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("sample", "0009_auto_20210407_1546"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="House", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ], 29 | bases=(model_clone.mixin.CloneMixin, models.Model), 30 | ), 31 | migrations.CreateModel( 32 | name="Room", 33 | fields=[ 34 | ( 35 | "id", 36 | models.AutoField( 37 | auto_created=True, 38 | primary_key=True, 39 | serialize=False, 40 | verbose_name="ID", 41 | ), 42 | ), 43 | ("name", models.CharField(max_length=255)), 44 | ( 45 | "house", 46 | models.ForeignKey( 47 | on_delete=django.db.models.deletion.CASCADE, 48 | related_name="rooms", 49 | to="sample.house", 50 | ), 51 | ), 52 | ], 53 | bases=(model_clone.mixin.CloneMixin, models.Model), 54 | ), 55 | migrations.CreateModel( 56 | name="Furniture", 57 | fields=[ 58 | ( 59 | "id", 60 | models.AutoField( 61 | auto_created=True, 62 | primary_key=True, 63 | serialize=False, 64 | verbose_name="ID", 65 | ), 66 | ), 67 | ("name", models.CharField(max_length=255)), 68 | ( 69 | "room", 70 | models.ForeignKey( 71 | on_delete=django.db.models.deletion.PROTECT, 72 | related_name="furniture", 73 | to="sample.room", 74 | ), 75 | ), 76 | ], 77 | bases=(model_clone.mixin.CloneMixin, models.Model), 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /sample/migrations/0011_auto_20210414_1744.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-14 17:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample", "0010_furniture_house_room"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="author", 14 | name="first_name", 15 | field=models.CharField(blank=True, max_length=200, unique=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="author", 19 | name="last_name", 20 | field=models.CharField(blank=True, max_length=200), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /sample/migrations/0012_backcover_cover.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-14 17:49 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import model_clone.mixin 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("sample", "0011_auto_20210414_1744"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Cover", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("content", models.CharField(max_length=200)), 28 | ( 29 | "book", 30 | models.OneToOneField( 31 | on_delete=django.db.models.deletion.CASCADE, to="sample.book" 32 | ), 33 | ), 34 | ], 35 | options={ 36 | "abstract": False, 37 | }, 38 | bases=(model_clone.mixin.CloneMixin, models.Model), 39 | ), 40 | migrations.CreateModel( 41 | name="BackCover", 42 | fields=[ 43 | ( 44 | "id", 45 | models.AutoField( 46 | auto_created=True, 47 | primary_key=True, 48 | serialize=False, 49 | verbose_name="ID", 50 | ), 51 | ), 52 | ("content", models.CharField(max_length=200)), 53 | ( 54 | "book", 55 | models.OneToOneField( 56 | on_delete=django.db.models.deletion.CASCADE, to="sample.book" 57 | ), 58 | ), 59 | ], 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /sample/migrations/0013_edition.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-04-20 00:23 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.db import migrations, models 6 | 7 | import model_clone.mixin 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("sample", "0012_backcover_cover"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Edition", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("seq", models.PositiveIntegerField()), 29 | ("created_at", models.DateTimeField(default=django.utils.timezone.now)), 30 | ( 31 | "book", 32 | models.ForeignKey( 33 | on_delete=django.db.models.deletion.CASCADE, 34 | related_name="editions", 35 | to="sample.book", 36 | ), 37 | ), 38 | ], 39 | bases=(model_clone.mixin.CloneMixin, models.Model), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /sample/migrations/0014_auto_20210422_1449.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-22 14:49 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import model_clone.mixin 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("sample", "0013_edition"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Tag", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ], 29 | options={ 30 | "abstract": False, 31 | }, 32 | bases=(model_clone.mixin.CloneMixin, models.Model), 33 | ), 34 | migrations.AlterField( 35 | model_name="assignment", 36 | name="id", 37 | field=models.BigAutoField( 38 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 39 | ), 40 | ), 41 | migrations.AlterField( 42 | model_name="author", 43 | name="id", 44 | field=models.BigAutoField( 45 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 46 | ), 47 | ), 48 | migrations.AlterField( 49 | model_name="backcover", 50 | name="id", 51 | field=models.BigAutoField( 52 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 53 | ), 54 | ), 55 | migrations.AlterField( 56 | model_name="book", 57 | name="id", 58 | field=models.BigAutoField( 59 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 60 | ), 61 | ), 62 | migrations.AlterField( 63 | model_name="cover", 64 | name="id", 65 | field=models.BigAutoField( 66 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 67 | ), 68 | ), 69 | migrations.AlterField( 70 | model_name="edition", 71 | name="id", 72 | field=models.BigAutoField( 73 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 74 | ), 75 | ), 76 | migrations.AlterField( 77 | model_name="furniture", 78 | name="id", 79 | field=models.BigAutoField( 80 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 81 | ), 82 | ), 83 | migrations.AlterField( 84 | model_name="house", 85 | name="id", 86 | field=models.BigAutoField( 87 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 88 | ), 89 | ), 90 | migrations.AlterField( 91 | model_name="page", 92 | name="id", 93 | field=models.BigAutoField( 94 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 95 | ), 96 | ), 97 | migrations.AlterField( 98 | model_name="room", 99 | name="id", 100 | field=models.BigAutoField( 101 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 102 | ), 103 | ), 104 | migrations.CreateModel( 105 | name="BookTag", 106 | fields=[ 107 | ( 108 | "id", 109 | models.BigAutoField( 110 | auto_created=True, 111 | primary_key=True, 112 | serialize=False, 113 | verbose_name="ID", 114 | ), 115 | ), 116 | ( 117 | "book", 118 | models.ForeignKey( 119 | on_delete=django.db.models.deletion.CASCADE, to="sample.book" 120 | ), 121 | ), 122 | ( 123 | "tag", 124 | models.ForeignKey( 125 | on_delete=django.db.models.deletion.PROTECT, to="sample.tag" 126 | ), 127 | ), 128 | ], 129 | options={ 130 | "unique_together": {("book", "tag")}, 131 | }, 132 | ), 133 | migrations.AddField( 134 | model_name="book", 135 | name="tags", 136 | field=models.ManyToManyField(through="sample.BookTag", to="sample.Tag"), 137 | ), 138 | ] 139 | -------------------------------------------------------------------------------- /sample/migrations/0015_auto_20210423_0935.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-23 09:35 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import model_clone.mixin 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("sample", "0014_auto_20210422_1449"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="SaleTag", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ], 29 | options={ 30 | "abstract": False, 31 | }, 32 | bases=(model_clone.mixin.CloneMixin, models.Model), 33 | ), 34 | migrations.CreateModel( 35 | name="BookSaleTag", 36 | fields=[ 37 | ( 38 | "id", 39 | models.BigAutoField( 40 | auto_created=True, 41 | primary_key=True, 42 | serialize=False, 43 | verbose_name="ID", 44 | ), 45 | ), 46 | ( 47 | "book", 48 | models.ForeignKey( 49 | on_delete=django.db.models.deletion.CASCADE, to="sample.book" 50 | ), 51 | ), 52 | ( 53 | "sale_tag", 54 | models.ForeignKey( 55 | on_delete=django.db.models.deletion.PROTECT, to="sample.saletag" 56 | ), 57 | ), 58 | ], 59 | options={ 60 | "unique_together": {("book", "sale_tag")}, 61 | }, 62 | bases=(model_clone.mixin.CloneMixin, models.Model), 63 | ), 64 | migrations.AddField( 65 | model_name="book", 66 | name="sale_tags", 67 | field=models.ManyToManyField( 68 | through="sample.BookSaleTag", to="sample.SaleTag" 69 | ), 70 | ), 71 | ] 72 | -------------------------------------------------------------------------------- /sample/migrations/0016_product.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-05-17 12:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample", "0015_auto_20210423_0935"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Product", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("name", models.TextField(unique=True)), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /sample/migrations/0017_auto_20210624_1117.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.4 on 2021-06-24 11:17 2 | 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("sample", "0016_product"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="author", 16 | name="uuid", 17 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 18 | ), 19 | migrations.AddField( 20 | model_name="library", 21 | name="uuid", 22 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /sample/migrations/0018_auto_20210628_2301.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-28 23:01 2 | 3 | from django.db import migrations, models 4 | from django.utils import timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("sample", "0017_auto_20210624_1117"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="author", 15 | name="last_name", 16 | field=models.CharField(blank=True, default="Unknown", max_length=200), 17 | ), 18 | migrations.AlterField( 19 | model_name="author", 20 | name="sex", 21 | field=models.CharField( 22 | choices=[("U", "Unknown"), ("F", "Female"), ("M", "Male")], 23 | default="U", 24 | max_length=1, 25 | ), 26 | ), 27 | migrations.AddField( 28 | model_name="book", 29 | name="published_at", 30 | field=models.DateTimeField(blank=True, default=timezone.now, null=True), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /sample/migrations/0019_saletag_sale_tag_unique_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-07-17 20:26 2 | 3 | import django 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("sample", "0018_auto_20210628_2301"), 10 | ] 11 | 12 | operations = ( 13 | [ 14 | migrations.AddConstraint( 15 | model_name="saletag", 16 | constraint=models.UniqueConstraint( 17 | fields=("name",), name="sale_tag_unique_name" 18 | ), 19 | ), 20 | ] 21 | if django.VERSION >= (2, 2) 22 | else [ 23 | migrations.AlterField( 24 | model_name="saletag", 25 | name="name", 26 | field=models.CharField(max_length=255, unique=True), 27 | ), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /sample/migrations/0020_auto_20210717_2230.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-07-17 22:30 2 | import django 3 | from django.db import migrations, models 4 | 5 | import sample.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("sample", "0019_saletag_sale_tag_unique_name"), 11 | ] 12 | 13 | operations = ( 14 | [ 15 | migrations.AlterField( 16 | model_name="tag", 17 | name="name", 18 | field=models.CharField( 19 | default=sample.models.get_unique_tag_name, max_length=255 20 | ), 21 | ), 22 | migrations.AddConstraint( 23 | model_name="tag", 24 | constraint=models.UniqueConstraint( 25 | fields=("name",), name="tag_unique_name" 26 | ), 27 | ), 28 | ] 29 | if django.VERSION >= (2, 2) 30 | else [ 31 | migrations.AlterField( 32 | model_name="tag", 33 | name="name", 34 | field=models.CharField( 35 | default=sample.models.get_unique_tag_name, 36 | max_length=255, 37 | unique=True, 38 | ), 39 | ), 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /sample/migrations/0021_book_custom_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-12 01:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample", "0020_auto_20210717_2230"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="book", 14 | name="custom_slug", 15 | field=models.SlugField(default=""), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /sample/migrations/0022_ending_sentence.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-11-09 09:02 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import model_clone.mixin 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("sample", "0021_book_custom_slug"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Sentence", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("value", models.TextField()), 28 | ], 29 | bases=(model_clone.mixin.CloneMixin, models.Model), 30 | ), 31 | migrations.CreateModel( 32 | name="Ending", 33 | fields=[ 34 | ( 35 | "id", 36 | models.BigAutoField( 37 | auto_created=True, 38 | primary_key=True, 39 | serialize=False, 40 | verbose_name="ID", 41 | ), 42 | ), 43 | ( 44 | "sentence", 45 | models.OneToOneField( 46 | on_delete=django.db.models.deletion.CASCADE, 47 | related_name="ending", 48 | to="sample.sentence", 49 | ), 50 | ), 51 | ], 52 | bases=(model_clone.mixin.CloneMixin, models.Model), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /sample/migrations/0023_alter_edition_book.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-06-24 13:32 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("sample", "0022_ending_sentence"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="edition", 15 | name="book", 16 | field=models.ForeignKey( 17 | null=True, 18 | on_delete=django.db.models.deletion.CASCADE, 19 | related_name="editions", 20 | to="sample.book", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /sample/migrations/0024_alter_edition_book.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-06-24 13:35 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("sample", "0023_alter_edition_book"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="edition", 15 | name="book", 16 | field=models.ForeignKey( 17 | blank=True, 18 | null=True, 19 | on_delete=django.db.models.deletion.CASCADE, 20 | related_name="editions", 21 | to="sample.book", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /sample/migrations/0025_auto_20221029_0402.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2022-10-29 04:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample", "0024_alter_edition_book"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Editor", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("name", models.CharField(max_length=255, unique=True)), 25 | ], 26 | ), 27 | migrations.AddField( 28 | model_name="book", 29 | name="editors", 30 | field=models.ManyToManyField(related_name="books", to="sample.Editor"), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /sample/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample/migrations/__init__.py -------------------------------------------------------------------------------- /sample/models.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import django 4 | from django.conf import settings 5 | from django.db import models 6 | from django.utils import timezone 7 | from django.utils.translation import gettext as _ 8 | 9 | from model_clone.utils import get_unique_default 10 | 11 | if django.VERSION >= (2, 2): 12 | from django.db.models import UniqueConstraint 13 | 14 | from model_clone import CloneMixin 15 | from model_clone.models import CloneModel 16 | 17 | 18 | class Author(CloneModel): 19 | first_name = models.CharField(max_length=200, blank=True, unique=True) 20 | last_name = models.CharField(default="Unknown", max_length=200, blank=True) 21 | age = models.PositiveIntegerField() 22 | 23 | uuid = models.UUIDField(default=uuid4, unique=True, editable=False) 24 | 25 | SEX_CHOICES = [ 26 | ("U", "Unknown"), 27 | ("F", "Female"), 28 | ("M", "Male"), 29 | ] 30 | sex = models.CharField(choices=SEX_CHOICES, max_length=1, default="U") 31 | created_by = models.ForeignKey( 32 | settings.AUTH_USER_MODEL, 33 | on_delete=models.PROTECT, 34 | ) 35 | 36 | def __str__(self): 37 | return _("{} {}".format(self.first_name, self.last_name)) 38 | 39 | @property 40 | def full_name(self): 41 | return "{} {}".format(self.first_name, self.last_name) 42 | 43 | class Meta: 44 | unique_together = (("first_name", "last_name", "sex"),) 45 | 46 | 47 | def get_unique_tag_name(): 48 | return get_unique_default( 49 | model=Tag, 50 | fname="name", 51 | value="test-tag", 52 | ) 53 | 54 | 55 | class Tag(CloneModel): 56 | name = models.CharField( 57 | max_length=255, default=get_unique_tag_name, unique=django.VERSION < (2, 2) 58 | ) 59 | 60 | if django.VERSION >= (2, 2): 61 | 62 | class Meta: 63 | constraints = [ 64 | UniqueConstraint(fields=["name"], name="tag_unique_name"), 65 | ] 66 | 67 | def __str__(self): 68 | return _(self.name) 69 | 70 | 71 | class SaleTag(CloneModel): 72 | name = models.CharField(max_length=255, unique=django.VERSION < (2, 2)) 73 | 74 | if django.VERSION >= (2, 2): 75 | 76 | class Meta: 77 | constraints = [ 78 | UniqueConstraint(fields=["name"], name="sale_tag_unique_name"), 79 | ] 80 | 81 | def __str__(self): 82 | return _(self.name) 83 | 84 | 85 | class Editor(models.Model): 86 | name = models.CharField(max_length=255, unique=True) 87 | 88 | def __str__(self): 89 | return _(self.name) 90 | 91 | 92 | class Book(CloneModel): 93 | name = models.CharField(max_length=2000) 94 | slug = models.SlugField(unique=True) 95 | custom_slug = models.SlugField(default="") 96 | authors = models.ManyToManyField(Author, related_name="books") 97 | editors = models.ManyToManyField(Editor, related_name="books") 98 | created_by = models.ForeignKey( 99 | settings.AUTH_USER_MODEL, 100 | on_delete=models.PROTECT, 101 | ) 102 | created_at = models.DateTimeField(auto_now_add=True) 103 | tags = models.ManyToManyField(Tag, through="BookTag") 104 | sale_tags = models.ManyToManyField(SaleTag, through="BookSaleTag") 105 | published_at = models.DateTimeField(null=True, blank=True, default=timezone.now) 106 | 107 | def __str__(self): 108 | return _(self.name) 109 | 110 | 111 | class BookTag(models.Model): 112 | book = models.ForeignKey(Book, on_delete=models.CASCADE) 113 | tag = models.ForeignKey(Tag, on_delete=models.PROTECT) 114 | 115 | class Meta: 116 | unique_together = [ 117 | ("book", "tag"), 118 | ] 119 | 120 | 121 | class BookSaleTag(CloneModel): 122 | book = models.ForeignKey(Book, on_delete=models.CASCADE) 123 | sale_tag = models.ForeignKey(SaleTag, on_delete=models.PROTECT) 124 | 125 | class Meta: 126 | unique_together = [ 127 | ("book", "sale_tag"), 128 | ] 129 | 130 | 131 | class Page(models.Model): 132 | content = models.CharField(max_length=20000) 133 | book = models.ForeignKey(Book, on_delete=models.CASCADE) 134 | created_at = models.DateTimeField(default=timezone.now) 135 | 136 | 137 | class Edition(CloneModel): 138 | seq = models.PositiveIntegerField() 139 | book = models.ForeignKey( 140 | Book, on_delete=models.CASCADE, null=True, related_name="editions", blank=True 141 | ) 142 | created_at = models.DateTimeField(default=timezone.now) 143 | 144 | 145 | class Library(CloneModel): 146 | id = models.UUIDField(primary_key=True, default=uuid4) 147 | name = models.CharField(max_length=100) 148 | user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) 149 | 150 | uuid = models.UUIDField(default=uuid4, unique=True, editable=False) 151 | 152 | _clone_fields = ["name"] 153 | _clone_o2o_fields = ["user"] 154 | 155 | class Meta: 156 | verbose_name_plural = _("libraries") 157 | 158 | def __str__(self): 159 | return _(self.name) 160 | 161 | 162 | class Assignment(CloneMixin, models.Model): 163 | created_at = models.DateTimeField(auto_now_add=True, null=True) 164 | updated_at = models.DateTimeField(auto_now=True, null=True) 165 | title = models.CharField( 166 | max_length=100, default="", blank=True, verbose_name=_("Job title") 167 | ) 168 | assignment_date = models.DateField(blank=True, null=True) 169 | company = models.ForeignKey( 170 | "sample_company.CompanyDepot", 171 | null=True, 172 | blank=True, 173 | on_delete=models.CASCADE, 174 | verbose_name=_("Company"), 175 | ) 176 | applied_drivers = models.ManyToManyField( 177 | "sample_driver.Driver", 178 | blank=True, 179 | verbose_name=_("Driver applications"), 180 | related_name="driver_applications", 181 | ) 182 | chosen_drivers = models.ManyToManyField( 183 | "sample_driver.Driver", 184 | blank=True, 185 | verbose_name=_("Chosen drivers"), 186 | ) 187 | 188 | contract = models.ForeignKey( 189 | "sample_assignment.Contract", 190 | null=True, 191 | blank=True, 192 | on_delete=models.SET_NULL, 193 | verbose_name=_("Choose contract"), 194 | related_name="assignment_contracts", 195 | ) 196 | 197 | ASSIGNMENT_STATUS = [ 198 | (1, "Complete"), 199 | (2, "Incomplete"), 200 | ] 201 | 202 | assignment_status = models.CharField( 203 | max_length=2, 204 | choices=ASSIGNMENT_STATUS, 205 | default="O", 206 | verbose_name=_("Assignment status"), 207 | blank=True, 208 | ) 209 | location = models.CharField(max_length=25, default="", verbose_name=_("Location")) 210 | 211 | DRIVER_TYPES = [ 212 | (1, "Commercial"), 213 | (2, "Residential"), 214 | ] 215 | 216 | driver_type = models.CharField( 217 | max_length=2, choices=DRIVER_TYPES, default="", verbose_name=_("Driver type") 218 | ) 219 | CAR_TYPES = [ 220 | (1, "Large"), 221 | (2, "Small"), 222 | ] 223 | car_type = models.CharField( 224 | max_length=2, choices=CAR_TYPES, default="", verbose_name=_("Car type") 225 | ) 226 | compensation = models.DecimalField( 227 | null=True, max_digits=5, decimal_places=2, verbose_name=_("Compensation") 228 | ) 229 | hours = models.IntegerField(verbose_name=_("Amount of hours")) 230 | spots_available = models.IntegerField(null=True, verbose_name=_("Spots available")) 231 | description = models.TextField(blank=True, verbose_name=_("Assignment description")) 232 | 233 | # Model clone settings 234 | _clone_excluded_m2m_fields = ["applied_drivers", "chosen_drivers"] 235 | 236 | class Meta: 237 | verbose_name = _("Assigment") 238 | verbose_name_plural = _("Assignments") 239 | 240 | def __str__(self): 241 | return self.title 242 | 243 | 244 | class House(CloneMixin, models.Model): 245 | name = models.CharField(max_length=255) 246 | 247 | _clone_fields = ["name"] 248 | _clone_m2o_or_o2m_fields = ["rooms"] 249 | 250 | def __str__(self): 251 | return self.name 252 | 253 | 254 | class Room(CloneMixin, models.Model): 255 | name = models.CharField(max_length=255) 256 | 257 | house = models.ForeignKey(House, related_name="rooms", on_delete=models.CASCADE) 258 | 259 | _clone_fields = ["name"] 260 | _clone_m2o_or_o2m_fields = ["furniture"] 261 | 262 | def __str__(self): 263 | return self.name 264 | 265 | 266 | class Furniture(CloneMixin, models.Model): 267 | name = models.CharField(max_length=255) 268 | 269 | room = models.ForeignKey(Room, related_name="furniture", on_delete=models.PROTECT) 270 | 271 | _clone_fields = ["name"] 272 | 273 | def __str__(self): 274 | return self.name 275 | 276 | 277 | class Cover(CloneModel): 278 | content = models.CharField(max_length=200) 279 | book = models.OneToOneField(Book, on_delete=models.CASCADE) 280 | 281 | 282 | class BackCover(models.Model): 283 | content = models.CharField(max_length=200) 284 | book = models.OneToOneField(Book, on_delete=models.CASCADE) 285 | 286 | 287 | class Product(CloneMixin, models.Model): 288 | name = models.TextField(unique=True) 289 | 290 | 291 | class Sentence(CloneMixin, models.Model): 292 | value = models.TextField() 293 | 294 | _clone_o2o_fields = {"ending"} 295 | 296 | 297 | class Ending(CloneMixin, models.Model): 298 | sentence = models.OneToOneField( 299 | Sentence, on_delete=models.CASCADE, related_name="ending" 300 | ) 301 | -------------------------------------------------------------------------------- /sample/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.utils import timezone 3 | 4 | from model_clone.signals import post_clone_save, pre_clone_save 5 | 6 | from .models import Edition 7 | 8 | 9 | @receiver(pre_clone_save, sender=Edition) 10 | def increase_seq(sender, instance, **kwargs): 11 | instance.seq += 1 12 | 13 | 14 | @receiver(post_clone_save, sender=Edition) 15 | def update_book_published_at(sender, instance, **kwargs): 16 | if instance.book: 17 | instance.book.published_at = timezone.now() 18 | instance.book.save(update_fields=["published_at"]) 19 | -------------------------------------------------------------------------------- /sample/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.views.generic import RedirectView 3 | 4 | try: 5 | from django.urls import re_path as path 6 | except ImportError: 7 | from django.conf.urls import url as path 8 | 9 | app_name = "sample" 10 | 11 | urlpatterns = [ 12 | path("^admin/", admin.site.urls, name="admin"), 13 | path("", RedirectView.as_view(pattern_name="admin:index")), 14 | ] 15 | -------------------------------------------------------------------------------- /sample_assignment/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample_assignment/__init__.py -------------------------------------------------------------------------------- /sample_assignment/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from sample_assignment.models import Contract 4 | 5 | 6 | @admin.register(Contract) 7 | class ContractAdmin(admin.ModelAdmin): 8 | pass 9 | -------------------------------------------------------------------------------- /sample_assignment/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AssignmentConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "sample_assignment" 7 | -------------------------------------------------------------------------------- /sample_assignment/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-16 17:33 2 | 3 | from django.db import migrations, models 4 | 5 | import model_clone.mixin 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Contract", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("title", models.CharField(max_length=255)), 27 | ], 28 | bases=(model_clone.mixin.CloneMixin, models.Model), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /sample_assignment/migrations/0002_alter_contract_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-22 14:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample_assignment", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="contract", 14 | name="id", 15 | field=models.BigAutoField( 16 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /sample_assignment/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample_assignment/migrations/__init__.py -------------------------------------------------------------------------------- /sample_assignment/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Contract(models.Model): 5 | title = models.CharField(max_length=255) 6 | 7 | def __str__(self): 8 | return self.title 9 | -------------------------------------------------------------------------------- /sample_company/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample_company/__init__.py -------------------------------------------------------------------------------- /sample_company/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CompanyConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "sample_company" 7 | -------------------------------------------------------------------------------- /sample_company/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-16 17:33 2 | 3 | from django.db import migrations, models 4 | 5 | import model_clone.mixin 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="CompanyDepot", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=255)), 27 | ], 28 | bases=(model_clone.mixin.CloneMixin, models.Model), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /sample_company/migrations/0002_alter_companydepot_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-22 14:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample_company", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="companydepot", 14 | name="id", 15 | field=models.BigAutoField( 16 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /sample_company/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample_company/migrations/__init__.py -------------------------------------------------------------------------------- /sample_company/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from model_clone import CloneMixin 4 | 5 | 6 | class CompanyDepot(CloneMixin, models.Model): 7 | name = models.CharField(max_length=255) 8 | -------------------------------------------------------------------------------- /sample_driver/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample_driver/__init__.py -------------------------------------------------------------------------------- /sample_driver/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DriverConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "sample_driver" 7 | -------------------------------------------------------------------------------- /sample_driver/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-16 17:33 2 | 3 | from django.db import migrations, models 4 | 5 | import model_clone.mixin 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Driver", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=255)), 27 | ("age", models.SmallIntegerField()), 28 | ], 29 | bases=(model_clone.mixin.CloneMixin, models.Model), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /sample_driver/migrations/0002_alter_driver_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2 on 2021-04-22 14:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample_driver", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="driver", 14 | name="id", 15 | field=models.BigAutoField( 16 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /sample_driver/migrations/0003_driverflag_driver_flags.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-27 19:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("sample_driver", "0002_alter_driver_id"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="DriverFlag", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("name", models.CharField(max_length=255, unique=True)), 25 | ], 26 | ), 27 | migrations.AddField( 28 | model_name="driver", 29 | name="flags", 30 | field=models.ManyToManyField(to="sample_driver.driverflag"), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /sample_driver/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tj-django/django-clone/a9d90db6c3f54fb31cd029df117e1eee8f061253/sample_driver/migrations/__init__.py -------------------------------------------------------------------------------- /sample_driver/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from model_clone import CloneMixin 4 | 5 | 6 | class DriverFlag(models.Model): 7 | name = models.CharField(max_length=255, unique=True) 8 | 9 | def __str__(self): 10 | return self.name 11 | 12 | 13 | class Driver(CloneMixin, models.Model): 14 | name = models.CharField(max_length=255) 15 | age = models.SmallIntegerField() 16 | flags = models.ManyToManyField(DriverFlag) 17 | 18 | _clone_linked_m2m_fields = ["flags"] 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .tox, 4 | sample/migrations, 5 | sample/views.py, 6 | sample/urls.py, 7 | django_clone/settings.py, 8 | manage.py, 9 | venv, 10 | max-line-length = 100 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | 4 | from setuptools import find_namespace_packages, setup 5 | 6 | install_requires = [ 7 | "django", 8 | "conditional>=1.3", 9 | "six", 10 | ] 11 | 12 | test_requires = [ 13 | "tox", 14 | "tox-gh-actions", 15 | "pluggy>=0.7", 16 | "mock", 17 | "unittest-xml-reporting", 18 | "codacy-coverage", 19 | "django-migration-fixer", 20 | ] 21 | 22 | deploy_requires = [ 23 | "bump2version", 24 | "readme_renderer[md]", 25 | "git-changelog", 26 | ] 27 | 28 | 29 | local_dev_requires = [ 30 | "pip-tools", 31 | "check-manifest", 32 | ] 33 | 34 | extras_require = { 35 | "development": [ 36 | local_dev_requires, 37 | install_requires, 38 | test_requires, 39 | deploy_requires, 40 | ], 41 | "test": test_requires, 42 | "deploy": deploy_requires, 43 | } 44 | 45 | BASE_DIR = os.path.dirname(__file__) 46 | README_PATH = os.path.join(BASE_DIR, "README.md") 47 | LONG_DESCRIPTION_TYPE = "text/markdown" 48 | 49 | if os.path.isfile(README_PATH): 50 | with io.open(README_PATH, encoding="utf-8") as f: 51 | LONG_DESCRIPTION = f.read() 52 | else: 53 | LONG_DESCRIPTION = "" 54 | 55 | setup( 56 | name="django-clone", 57 | version="5.3.3", 58 | description="Create a clone of a django model instance.", 59 | python_requires=">=3.6", 60 | long_description=LONG_DESCRIPTION, 61 | long_description_content_type=LONG_DESCRIPTION_TYPE, 62 | author="Tonye Jack", 63 | author_email="jtonye@ymail.com", 64 | maintainer="Tonye Jack", 65 | maintainer_email="jtonye@ymail.com", 66 | url="https://github.com/tj-django/django-clone.git", 67 | license="MIT/Apache-2.0", 68 | zip_safe=False, 69 | include_package_data=True, 70 | keywords=[ 71 | "django", 72 | "django-clone", 73 | "django clone", 74 | "django object clone", 75 | "clone-django", 76 | "model cloning", 77 | "django instance duplication", 78 | "django duplication", 79 | ], 80 | classifiers=[ 81 | "Development Status :: 5 - Production/Stable", 82 | "Intended Audience :: Developers", 83 | "License :: OSI Approved :: MIT License", 84 | "License :: OSI Approved :: Apache Software License", 85 | "Natural Language :: English", 86 | "Topic :: Internet :: WWW/HTTP", 87 | "Operating System :: OS Independent", 88 | "Programming Language :: Python :: 3.6", 89 | "Programming Language :: Python :: 3.7", 90 | "Programming Language :: Python :: 3.8", 91 | "Programming Language :: Python :: 3.9", 92 | "Programming Language :: Python :: 3.10", 93 | "Programming Language :: Python :: 3.11", 94 | "Programming Language :: Python :: Implementation :: CPython", 95 | "Programming Language :: Python :: Implementation :: PyPy", 96 | "Framework :: Django :: 2.0", 97 | "Framework :: Django :: 2.1", 98 | "Framework :: Django :: 2.2", 99 | "Framework :: Django :: 3.0", 100 | "Framework :: Django :: 3.1", 101 | "Framework :: Django :: 3.2", 102 | "Framework :: Django :: 4.0", 103 | "Framework :: Django :: 4.1", 104 | "Framework :: Django :: 4.2", 105 | ], 106 | install_requires=install_requires, 107 | tests_require=["coverage"], 108 | extras_require=extras_require, 109 | packages=find_namespace_packages( 110 | include=[ 111 | "model_clone.templates.admin", 112 | "model_clone", 113 | "model_clone.templates.clone", 114 | ], 115 | ), 116 | ) 117 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.8.0 3 | skipsdist = false 4 | envlist = 5 | py36-django{20,21,22,30,31,32,main}-{linux,macos,windows} 6 | py37-django{20,21,22,30,31,32,main}-{linux,macos,windows} 7 | py38-django{21,22,30,31,32,40,41,42,main}-{linux,macos,windows} 8 | py39-django{21,22,30,31,32,40,41,42,main}-{linux,macos,windows} 9 | py310-django{22,30,31,32,40,41,42,main}-{linux,macos,windows} 10 | py311-django{22,30,31,32,40,41,42,main}-{linux} 11 | skip_missing_interpreters = true 12 | 13 | [gh-actions] 14 | python = 15 | 3.6: py36 16 | 3.7: py37 17 | 3.8: py38 18 | 3.9: py39 19 | 3.10: py310 20 | 3.11: py311 21 | 22 | [gh-actions:env] 23 | PLATFORM = 24 | ubuntu-latest: linux 25 | macos-latest: macos 26 | windows-latest: windows 27 | 28 | [testenv] 29 | whitelist_externals = make 30 | setenv = DJANGO_SETTINGS_MODULE=django_clone.settings 31 | passenv = * 32 | extras = 33 | development 34 | test 35 | deps = 36 | django20: Django>=2.0,<2.1 37 | django21: Django>=2.1,<2.2 38 | django22: Django>=2.2,<2.3 39 | django30: Django>=3.0,<3.1 40 | django31: Django>=3.1,<3.2 41 | django32: Django>=3.2,<3.3 42 | django40: Django>=4.0,<4.1 43 | django41: Django>=4.1,<4.2 44 | django42: Django>=4.2,<4.3 45 | main: https://github.com/django/django/archive/main.tar.gz 46 | mock 47 | coverage 48 | pytest-django 49 | codacy-coverage 50 | usedevelop = true 51 | commands = 52 | coverage run manage.py test 53 | coverage xml 54 | - python-codacy-coverage -r coverage.xml 55 | --------------------------------------------------------------------------------