├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ ├── gh-pages.yml │ └── pypi.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.rst ├── LICENSE.txt ├── Makefile ├── README.rst ├── django_copyist ├── __init__.py ├── config.py ├── copy_request.py └── copyist.py ├── docs ├── Makefile ├── make.bat └── source │ ├── _static │ ├── logo-no-background.png │ └── logo.ico │ ├── api.rst │ ├── conf.py │ ├── index.rst │ ├── overview.rst │ ├── quickstart.rst │ └── toc.rst ├── example ├── __init__.py ├── asgi.py ├── demo │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ └── tests │ │ ├── __init__.py │ │ └── test_examples.py ├── settings.py ├── transport_network │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── network_copy_config.py │ ├── project_copy_config.py │ └── tests │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── factories.py │ │ └── test_copyist.py ├── urls.py └── wsgi.py ├── manage.py ├── poetry.lock ├── pyproject.toml └── pytest.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude= 3 | .git 4 | .meta 5 | .tox 6 | */tests/* 7 | max-line-length = 100 8 | extend-ignore = E203 9 | 10 | inline-quotes = " 11 | import-order-style=pep8 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior, preferably a small code snippet. 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Additional context** 17 | Add any other context about the problem here. 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context about the feature request here. 18 | 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Checklist: 16 | 17 | 18 | - [ ] My change requires a change to the documentation. 19 | - [ ] I have updated the documentation accordingly. 20 | - [ ] I have added the changelog accordingly. 21 | - [ ] I have added tests to cover my changes. 22 | - [ ] All new and existing tests passed. 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: [main] 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.11' 16 | - name: Install and configure Poetry 17 | run: | 18 | pip install -U pip poetry 19 | poetry config virtualenvs.create false 20 | - name: Install requirements 21 | run: make deps 22 | - name: Run lint 23 | run: make lint 24 | 25 | test: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | python-version: ["3.11", "3.12", "3.13"] 30 | steps: 31 | - uses: actions/cache@v4 32 | with: 33 | path: ~/.cache/pip 34 | key: ${{ runner.os }}-pip-${{ hashFiles('**/poetry.lock') }} 35 | restore-keys: | 36 | ${{ runner.os }}-pip- 37 | - uses: actions/checkout@v4 38 | - uses: actions/setup-python@v4 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | - name: Install and configure Poetry 42 | run: | 43 | pip install -U pip poetry 44 | poetry config virtualenvs.create false 45 | - name: Install requirements 46 | run: make deps 47 | - name: Run ci 48 | run: make test 49 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: gh-pages 2 | permissions: 3 | id-token: write 4 | pages: write 5 | on: 6 | workflow_dispatch: 7 | release: 8 | types: 9 | - created 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: "3.11" 18 | - name: Install and configure Poetry 19 | run: | 20 | pip install -U pip poetry 21 | poetry config virtualenvs.create false 22 | - name: Build docs 23 | run: make docs 24 | - uses: actions/upload-artifact@v4 25 | with: 26 | name: page 27 | path: docs/build/html 28 | if-no-files-found: error 29 | 30 | deploy: 31 | runs-on: ubuntu-latest 32 | needs: build 33 | environment: 34 | name: github-pages 35 | url: ${{steps.deployment.outputs.page_url}} 36 | 37 | steps: 38 | - uses: actions/download-artifact@v4 39 | with: 40 | name: page 41 | path: . 42 | - uses: actions/configure-pages@v5 43 | - uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: . 46 | - id: deployment 47 | uses: actions/deploy-pages@v4 48 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: pypi 2 | on: 3 | release: 4 | types: 5 | - created 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-python@v4 12 | with: 13 | python-version: '3.11' 14 | - name: Install and configure Poetry 15 | run: | 16 | pip install -U pip poetry 17 | poetry config virtualenvs.create false 18 | - name: Install requirements 19 | run: make deps 20 | - name: Build dists 21 | run: make build 22 | - name: Pypi Publish 23 | uses: pypa/gh-action-pypi-publish@release/v1 24 | with: 25 | user: __token__ 26 | password: ${{ secrets.pypi_password }} 27 | -------------------------------------------------------------------------------- /.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 | .idea 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | 165 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v3.15.2 4 | hooks: 5 | - id: pyupgrade 6 | args: [ --py311-plus ] 7 | 8 | - repo: https://github.com/pycqa/autoflake 9 | rev: v2.3.1 10 | hooks: 11 | - id: autoflake 12 | args: [ '--in-place', '--remove-all-unused-imports', '--remove-unused-variable','--exclude=__init__.py' ] 13 | 14 | - repo: https://github.com/pycqa/isort 15 | rev: 5.13.2 16 | hooks: 17 | - id: isort 18 | args: ["--profile", "black", "--filter-files"] 19 | 20 | - repo: https://github.com/psf/black 21 | rev: 24.4.2 22 | hooks: 23 | - id: black 24 | language_version: python3.11 25 | 26 | - repo: https://github.com/pycqa/flake8 27 | rev: 7.0.0 28 | hooks: 29 | - id: flake8 30 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | ========= 4 | Changelog 5 | ========= 6 | 7 | 8 | 0.1 9 | === 10 | 11 | 0.1.3 12 | ----- 13 | - Improved docs 14 | 15 | 0.1.1 16 | ----- 17 | 18 | - Specified python >=3.11 19 | 20 | 0.1.0 21 | ----- 22 | 23 | - Initial release -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Andrei Bondar 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deps: 2 | @poetry install 3 | 4 | build: deps 5 | rm -fR dist/ 6 | poetry build 7 | 8 | docs: deps 9 | sphinx-build -M html docs/source/ docs/build/ 10 | 11 | test: deps 12 | pytest example/demo/tests example/transport_network/tests 13 | 14 | lint: deps build 15 | pre-commit run -a 16 | 17 | ci: lint test 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-copyist 2 | ========================================== 3 | 4 | Tool for precise and efficient copying of Django models instances. 5 | 6 | Do you live in fear of the day when PM will come to you 7 | and ask to implement a copy feature for your root model, 8 | that has a lot of relations and you know that it will be a pain to implement it in a way that will work properly? 9 | 10 | Well, fear no more, as `django-copyist` got you covered 11 | 12 | 13 | Features 14 | -------- 15 | 16 | - **Precision** - Copy only what you need in a way that you need with help of custom copy actions. 17 | - **Readability** - With declarative style config - you can easily see what and how is copied, no need to hop between models and parse it in your head. 18 | - **Efficiency** - Unlike some other solutions and naive approaches, copyist is copying your data without recursive hopping between model instances, which gives it a magnitudes of speedup on big data sets. 19 | - **Flexibility** - Copyist covers all steps that are there for data copy, including validation of data, pre-copy and post-copy actions. Copyist also work good with de-normalized data, not judging you for your choices. 20 | - **Interactive** - Copyist provides a way to interact with the copy process, allowing application to see what exactly is going to be done, and propagate that info to end user to decide if he wants to go through with it. 21 | - **Reusability** - With copyist your copy flow is not nailed down to model, allowing you defining different approaches for same model, and at the same time reuse existing configurations. 22 | 23 | Motivation 24 | ---------- 25 | 26 | This project was build as in-house tool for project with complex hierarchy of models, 27 | where we needed to copy them in a very particular way. 28 | 29 | Existing solutions like `django-clone `_ were designed 30 | in a way that didn't fit our needs, as they required to modify models and 31 | didn't allow to have full control over the copying process. 32 | 33 | This project aims to provide a more flexible and efficient way to copy Django models instances, while 34 | not affecting existing codebase. 35 | 36 | Quickstart 37 | ========== 38 | 39 | This pages aims to get you going with django-copyist as quickly as possible. 40 | 41 | Installation 42 | ------------ 43 | 44 | To install with pip: 45 | 46 | .. code-block:: console 47 | 48 | pip install django-copyist 49 | 50 | To install with poetry: 51 | 52 | .. code-block:: console 53 | 54 | poetry add django-copyist 55 | 56 | 57 | Usage 58 | ----------- 59 | 60 | `See quickstart in docs `_ 61 | -------------------------------------------------------------------------------- /django_copyist/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/django_copyist/__init__.py -------------------------------------------------------------------------------- /django_copyist/config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from enum import StrEnum 3 | from typing import TYPE_CHECKING, Any, List, Optional, Protocol 4 | 5 | from django.db.models import Model, Q, QuerySet 6 | 7 | if TYPE_CHECKING: 8 | from django_copyist.copyist import ( 9 | CopyIntent, 10 | FieldSetToFilterMap, 11 | IgnoredMap, 12 | OutputMap, 13 | SetToFilterMap, 14 | ) 15 | 16 | 17 | class IgnoreFilterSource(StrEnum): 18 | UNMATCHED_SET_TO_FILTER_VALUES = "UNMATCHED_SET_TO_FILTER_VALUES" 19 | 20 | 21 | @dataclass 22 | class IgnoreFilter: 23 | """ 24 | This configuration describes a filter for excluding certain models during the copying process. 25 | 26 | It is applicable when you wish to exclude a model from being copied if some 27 | of its fields do not match any existing data. 28 | This is particularly useful when using the SET_TO_FILTER action. 29 | 30 | :param filter_name: The name of the filter field that will be used 31 | to exclude all models that do not match any existing data. Usually is an `__in` filter, 32 | as it will be given list of unmatched ids to exclude. 33 | e.g. if you have hierarchy `Parent -> Child <-M2M-> Tag` and want to ignore all parents, 34 | where child didn't match any of its tags, you can use `child_set__tags__id__in` 35 | :type filter_name: str 36 | :param filter_source: The value of the IgnoreFilterSource enum. 37 | It is always set to UNMATCHED_SET_TO_FILTER_VALUES. 38 | :type filter_source: IgnoreFilterSource 39 | :param set_to_filter_origin_model: The type of the model in which 40 | unmatched fields for the SET_TO_FILTER action are expected. 41 | :type set_to_filter_origin_model: Type[Model] 42 | :param set_to_filter_field_name: The name of the field for which unmatched values are expected. 43 | :type set_to_filter_field_name: str 44 | """ 45 | 46 | filter_name: str 47 | set_to_filter_origin_model: type[Model] 48 | set_to_filter_field_name: str 49 | filter_source: IgnoreFilterSource = ( 50 | IgnoreFilterSource.UNMATCHED_SET_TO_FILTER_VALUES 51 | ) 52 | 53 | 54 | class IgnoreFunc(Protocol): 55 | @staticmethod 56 | def __call__( 57 | model_config: "ModelCopyConfig", 58 | set_to_filter_map: "SetToFilterMap", 59 | model_extra_filter: Q | None, 60 | ignored_map: "IgnoredMap", 61 | input_data: dict[str, Any], 62 | ) -> list[Model]: 63 | """ 64 | A protocol that defines a function to be used for ignoring certain models 65 | during the copying process. 66 | 67 | This function is expected to take several parameters including the model configuration, 68 | set to filter map, model extra filter, ignored map, and input data. It should return a 69 | list of models that are to be excluded from copying. 70 | 71 | :param model_config: The configuration for the model being copied. 72 | :type model_config: ModelCopyConfig 73 | :param set_to_filter_map: The current global set to filter map. 74 | :type set_to_filter_map: SetToFilterMap 75 | :param model_extra_filter: An optional Q instance, representing current 76 | filters for model. It is generated by copyist during copy process 77 | (e.g. to filter instance list by parent id). 78 | You should apply it to your query to narrow down the list of models 79 | you are working with. 80 | :type model_extra_filter: Q, optional 81 | :param ignored_map: The current global ignored map. 82 | :type ignored_map: IgnoredMap 83 | :param input_data: The input data for the copy request. 84 | :type input_data: Dict[str, Any] 85 | :return: The list of models that are to be excluded from copying. 86 | :rtype: List[Model] 87 | """ 88 | ... 89 | 90 | 91 | @dataclass 92 | class IgnoreCondition: 93 | """ 94 | This configuration describes the conditions under which the copying 95 | of a model should be disregarded. 96 | 97 | Either 'filter_conditions' or 'ignore_func' must be specified. 98 | 99 | Attributes: 100 | filter_conditions: An optional list of IgnoreFilter instances. 101 | If provided, these will be used to filter out models that should not be copied. 102 | ignore_func: A function that, when provided, should return a 103 | list of models that are to be excluded from copying. 104 | """ 105 | 106 | filter_conditions: list[IgnoreFilter] | None = None 107 | ignore_func: IgnoreFunc | None = None 108 | 109 | def __post_init__(self): 110 | if bool(self.ignore_func) == bool(self.filter_conditions): 111 | raise ValueError( 112 | "Only one of filter_conditions and ignore_func should be declared" 113 | ) 114 | 115 | 116 | class CopyActions(StrEnum): 117 | TAKE_FROM_ORIGIN = "TAKE_FROM_ORIGIN" 118 | TAKE_FROM_INPUT = "TAKE_FROM_INPUT" 119 | MAKE_COPY = "MAKE_COPY" 120 | UPDATE_TO_COPIED = "UPDATE_TO_COPIED" 121 | SET_TO_FILTER = "SET_TO_FILTER" 122 | 123 | 124 | class FilterSource(StrEnum): 125 | FROM_INPUT = "FROM_INPUT" 126 | FROM_ORIGIN = "FROM_ORIGIN" 127 | 128 | 129 | @dataclass 130 | class FieldFilterConfig: 131 | """ 132 | This configuration specifies the source from which the filter value is obtained. 133 | 134 | If the source is FROM_ORIGIN, the filter value is derived from the original model. 135 | If the source is FROM_INPUT, the filter value is extracted from the input data. 136 | 137 | :param source: A FilterSource enumeration value. It can be either FROM_INPUT or FROM_ORIGIN. 138 | :type source: FilterSource 139 | :param key: An optional key in the input data that identifies the filter value. 140 | This should be specified if the source is FROM_INPUT, defaults to None. 141 | :type key: str, optional 142 | """ 143 | 144 | source: FilterSource 145 | key: str | None = None 146 | 147 | def __post_init__(self): 148 | if self.source == FilterSource.FROM_INPUT and not self.key: 149 | raise ValueError("FROM_INPUT filter source should define key") 150 | 151 | if self.source != FilterSource.FROM_INPUT and self.key: 152 | raise ValueError("key should not be defined if not FROM_INPUT type") 153 | 154 | 155 | class SetToFilterFunc(Protocol): 156 | @staticmethod 157 | def __call__( 158 | model_config: "ModelCopyConfig", 159 | input_data: dict[str, Any], 160 | field_name: str, 161 | field_copy_config: "FieldCopyConfig", 162 | set_to_filter_map: "SetToFilterMap", 163 | instance_list: list[Model], 164 | referenced_instance_list: list[Model], 165 | ) -> "FieldSetToFilterMap": 166 | """ 167 | A protocol that defines a function to be used for setting the filter map for a field. 168 | 169 | This function is expected to take several parameters including the model configuration, 170 | input data, field name, field copy configuration, set to filter map, instance list, 171 | and referenced instance list. It should return a FieldSetToFilterMap, which stores mapping 172 | of original object id string to substitute id or None. 173 | 174 | :param model_config: The configuration for the model being copied. 175 | :type model_config: ModelCopyConfig 176 | :param input_data: The input data for the copy request. 177 | :type input_data: Dict[str, Any] 178 | :param field_name: The name of the field for which the filter map is being set. 179 | :type field_name: str 180 | :param field_copy_config: The copy configuration for the SET_TO_FILTER field. 181 | :type field_copy_config: FieldCopyConfig 182 | :param set_to_filter_map: The current global set to filter map. 183 | :type set_to_filter_map: SetToFilterMap 184 | :param instance_list: The list of instances of the model being copied. 185 | :type instance_list: List[Model] 186 | :param referenced_instance_list: The list of instances that are referenced by the field. 187 | :type referenced_instance_list: List[Model] 188 | :return: The updated set to filter map for the field. 189 | :rtype: FieldSetToFilterMap 190 | """ 191 | ... 192 | 193 | 194 | @dataclass 195 | class FilterConfig: 196 | """ 197 | This class is a configuration that describes how a field's value 198 | should be queried from existing data. 199 | 200 | :param filters: A dictionary storing information on how a field should be queried. 201 | The key is the field name, and the value is a FieldFilterConfig instance. 202 | If a field is not present in filters, 203 | it will be ignored and the default for the model will be used, defaults to None. 204 | :type filters: Dict[str, FieldFilterConfig], optional 205 | :param filter_func: An optional instance of SetToFilterFunc. 206 | If present, it will be used for querying the field value from existing data, 207 | defaults to None. 208 | :type filter_func: SetToFilterFunc, optional 209 | """ 210 | 211 | filters: dict[str, FieldFilterConfig] | None = None 212 | filter_func: SetToFilterFunc | None = None 213 | 214 | def __post_init__(self): 215 | if bool(self.filters) == bool(self.filter_func): 216 | raise ValueError( 217 | "Filter config should define one of: 'filters', 'filter_func'" 218 | ) 219 | 220 | 221 | @dataclass 222 | class FieldCopyConfig: 223 | """ 224 | This class is a configuration that describes how a specific field should be copied. 225 | 226 | :param action: The action to be executed. 227 | :type action: CopyAction 228 | :param copy_with_config: A nested instance of ModelCopyConfig. 229 | This should be defined if the action is MAKE_COPY. 230 | It describes how a nested model should be copied, defaults to None. 231 | :type copy_with_config: ModelCopyConfig, optional 232 | :param reference_to: The model type to which the field should be updated. 233 | This should be defined if the action is UPDATE_TO_COPIED or SET_TO_FILTER, 234 | defaults to None. 235 | :type reference_to: Model, optional 236 | :param filter_config: An instance of FilterConfig. 237 | This should be defined if the action is SET_TO_FILTER. It describes how the value for the 238 | field should be queried from existing data, defaults to None. 239 | :type filter_config: FilterConfig, optional 240 | :param input_key: The key in the input data from which the value for the field should be taken. 241 | This should be defined if the action is TAKE_FROM_INPUT, defaults to None. 242 | :type input_key: str, optional 243 | :raises ValueError: If the action is MAKE_COPY and copy_with_config is not defined, 244 | or if the action is UPDATE_TO_COPIED and reference_to is not defined, 245 | or if the action is SET_TO_FILTER and either filter_config 246 | or reference_to is not defined, or if the action is TAKE_FROM_INPUT 247 | and input_key is not defined. 248 | """ 249 | 250 | action: CopyActions 251 | copy_with_config: Optional["ModelCopyConfig"] = None 252 | reference_to: type[Model] | None = None 253 | filter_config: FilterConfig | None = None 254 | input_key: str | None = None 255 | 256 | def __post_init__(self): 257 | if self.action == CopyActions.MAKE_COPY and not self.copy_with_config: 258 | raise ValueError("MAKE_COPY action should define copy_with_config") 259 | elif self.action == CopyActions.UPDATE_TO_COPIED and not self.reference_to: 260 | raise ValueError("UPDATE_TO_COPIED should define reference_to") 261 | elif self.action == CopyActions.SET_TO_FILTER: 262 | if not self.filter_config: 263 | raise ValueError("SET_TO_FILTER should define filter_config") 264 | if not self.reference_to: 265 | raise ValueError("SET_TO_FILTER should define reference_to") 266 | elif self.action == CopyActions.TAKE_FROM_INPUT and not self.input_key: 267 | raise ValueError("TAKE_FROM_INPUT should define input_key") 268 | 269 | 270 | TAKE_FROM_ORIGIN = FieldCopyConfig(action=CopyActions.TAKE_FROM_ORIGIN) 271 | """ 272 | Shortcut for creating FieldCopyConfig with TAKE_FROM_ORIGIN action 273 | """ 274 | 275 | 276 | def UpdateToCopied(reference: type[Model]) -> FieldCopyConfig: 277 | """ 278 | This function is a shortcut for creating a FieldCopyConfig instance with the 279 | UPDATE_TO_COPIED action. 280 | 281 | :param reference: The type of the model to which the field should be updated. 282 | :type reference: Type[Model] 283 | :return: A FieldCopyConfig instance with the action 284 | set to UPDATE_TO_COPIED and the reference set to the provided model type. 285 | :rtype: FieldCopyConfig 286 | """ 287 | return FieldCopyConfig(action=CopyActions.UPDATE_TO_COPIED, reference_to=reference) 288 | 289 | 290 | def MakeCopy(config: "ModelCopyConfig") -> FieldCopyConfig: 291 | """ 292 | This function is a shortcut for creating a FieldCopyConfig instance with the MAKE_COPY action. 293 | 294 | :param config: A nested instance of ModelCopyConfig. 295 | This describes how a nested model should be copied. 296 | :type config: ModelCopyConfig 297 | :return: A FieldCopyConfig instance with the action set to 298 | MAKE_COPY and the copy_with_config set to the provided config. 299 | :rtype: FieldCopyConfig 300 | """ 301 | return FieldCopyConfig(action=CopyActions.MAKE_COPY, copy_with_config=config) 302 | 303 | 304 | class DataModificationActions(StrEnum): 305 | DELETE_BY_FILTER = "DELETE_BY_FILTER" 306 | EXECUTE_FUNC = "EXECUTE_FUNC" 307 | 308 | 309 | class DataPreparationFunc(Protocol): 310 | @staticmethod 311 | def __call__( 312 | model_config: "ModelCopyConfig", 313 | input_data: dict[str, Any], 314 | set_to_filter_map: "SetToFilterMap", 315 | output_map: "OutputMap", 316 | ) -> None: 317 | """ 318 | A protocol that defines a function to be used for preparing data before the copying process. 319 | 320 | This function is expected to take several parameters including the model configuration, 321 | input data, set to filter map, and output map. It does not return any value. 322 | 323 | :param model_config: The configuration for the model being copied. 324 | :type model_config: ModelCopyConfig 325 | :param input_data: The input data for the copy request. 326 | :type input_data: Dict[str, Any] 327 | :param set_to_filter_map: The current global set to filter map. 328 | :type set_to_filter_map: SetToFilterMap 329 | :param output_map: The current global output map. 330 | :type output_map: OutputMap 331 | """ 332 | ... 333 | 334 | 335 | @dataclass 336 | class DataModificationStep: 337 | action: DataModificationActions 338 | filter_field_to_input_key: dict[str, str] | None = None 339 | func = None 340 | 341 | def __post_init__(self): 342 | if bool(self.func) == bool(self.filter_field_to_input_key): 343 | raise ValueError( 344 | "Only one of func and filter_field_to_input_key should be declared" 345 | ) 346 | 347 | 348 | @dataclass 349 | class DataPreparationStep(DataModificationStep): 350 | func: DataPreparationFunc | None = None 351 | 352 | 353 | class PostcopyFunc(Protocol): 354 | @staticmethod 355 | def __call__( 356 | model_config: "ModelCopyConfig", 357 | input_data: dict[str, Any], 358 | set_to_filter_map: "SetToFilterMap", 359 | output_map: "OutputMap", 360 | copy_intent_list: "List[CopyIntent]", 361 | ) -> None: 362 | """ 363 | A protocol that defines a function to be used for post-copy operations. 364 | 365 | This function is expected to take several parameters including the model configuration, 366 | input data, set to filter map, output map, and a list of copy intents. 367 | It does not return any value. 368 | 369 | :param model_config: The configuration for the model being copied. 370 | :type model_config: ModelCopyConfig 371 | :param input_data: The input data for the copy request. 372 | :type input_data: Dict[str, Any] 373 | :param set_to_filter_map: The current global set to filter map. 374 | :type set_to_filter_map: SetToFilterMap 375 | :param output_map: The current global output map. 376 | :type output_map: OutputMap 377 | :param copy_intent_list: The list of copy intents. 378 | Copy intent stores information about original and copied model 379 | :type copy_intent_list: List[CopyIntent] 380 | """ 381 | ... 382 | 383 | 384 | @dataclass 385 | class PostcopyStep(DataModificationStep): 386 | """ 387 | This class represents a step to be executed after the copying of model data. 388 | 389 | :param action: The action to be executed. 390 | :type action: Action 391 | :param filter_field_to_input_key: An optional mapping of model 392 | field names to input data keys. This is used for the `DELETE_BY_FILTER` action. 393 | If present, it will be used as an additional filter for querying the given model, 394 | defaults to None. 395 | :type filter_field_to_input_key: dict, optional 396 | :param func: An optional instance of PostcopyFunc. 397 | If present, it will be executed after the copying of model data, defaults to None. 398 | :type func: PostcopyFunc, optional 399 | """ 400 | 401 | func: PostcopyFunc | None = None 402 | 403 | 404 | @dataclass 405 | class ModelCopyConfig: 406 | """ 407 | This class is a configuration that describes how a specific model should be copied. 408 | 409 | :param model: The root model class that should be copied. 410 | :type model: Type[Model] 411 | :param field_copy_actions: A dictionary storing information on copying each field. 412 | The key is the field name, and the value is a FieldCopyConfig instance. 413 | If a field is not present in field_copy_actions, 414 | it will be ignored and the default for the model will be used. 415 | :type field_copy_actions: Dict[str, FieldCopyConfig] 416 | :param ignore_condition: An optional instance of IgnoreCondition. 417 | This describes the condition on which copying of the model should be ignored, 418 | defaults to None. 419 | :type ignore_condition: IgnoreCondition, optional 420 | :param compound_copy_actions: A list of ModelCopyConfig instances. 421 | The copying for these instances should be executed after copying the current model. 422 | This should be used if describing other models in a nested style in 423 | field_copy_actions is not appropriate 424 | (e.g., when copying another model depends on multiple fields), defaults to None. 425 | :type compound_copy_actions: List[ModelCopyConfig], optional 426 | :param filter_field_to_input_key: A dictionary mapping of model field names, 427 | which are used for the initial querying of the model, 428 | to input data key (e.g., {"id": "player_id"} if you want to query 429 | by Player.id and you have the field "player_id" in input_data), defaults to None. 430 | :type filter_field_to_input_key: Dict[str, str], optional 431 | :param data_preparation_steps: A list of DataPreparationStep instances. 432 | These can be used if special actions are needed to prepare the database state 433 | before copying, for example, if you need to delete some data in the 434 | target "location" before copying data from the origin, defaults to None. 435 | :type data_preparation_steps: List[DataPreparationStep], optional 436 | :param postcopy_steps: A list of PostcopyStep instances. These can be used if 437 | special actions are needed after the model data is copied, defaults to None. 438 | :type postcopy_steps: List[PostcopyStep], optional 439 | :param static_filters: An optional Q instance. If present, this will be used 440 | as an additional filter for querying the given model, defaults to None. 441 | :type static_filters: Q, optional 442 | """ 443 | 444 | model: type[Model] 445 | field_copy_actions: dict[str, FieldCopyConfig] 446 | ignore_condition: IgnoreCondition | None = None 447 | compound_copy_actions: list["ModelCopyConfig"] = field(default_factory=list) 448 | filter_field_to_input_key: dict[str, str] = field(default_factory=dict) 449 | data_preparation_steps: list[DataPreparationStep] = field(default_factory=list) 450 | postcopy_steps: list[PostcopyStep] = field(default_factory=list) 451 | static_filters: Q | None = None 452 | 453 | 454 | def get_queryset_for_model_config( 455 | model_config: ModelCopyConfig, 456 | extra_filters: Q | None, 457 | input_data: dict[str, Any], 458 | ) -> QuerySet: 459 | model_class = model_config.model 460 | if not (model_config.filter_field_to_input_key or extra_filters): 461 | raise ValueError( 462 | f"get_instances_for_model_config was called without filters for {model_class.__name__}" 463 | ) 464 | 465 | query = model_class.objects.all() 466 | if extra_filters: 467 | query = query.filter(extra_filters) 468 | 469 | if model_config.filter_field_to_input_key: 470 | for filter_field, input_key in model_config.filter_field_to_input_key.items(): 471 | input_value = input_data.get(input_key) 472 | if not input_value: 473 | raise ValueError( 474 | f"Filter {filter_field} value with key {input_key} was not found in input_data" 475 | ) 476 | query = query.filter(**{filter_field: input_value}) 477 | 478 | if model_config.static_filters: 479 | query = query.filter(model_config.static_filters) 480 | 481 | query = query.distinct() 482 | return query 483 | -------------------------------------------------------------------------------- /django_copyist/copy_request.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from dataclasses import dataclass 3 | from enum import StrEnum 4 | from typing import Any, Optional 5 | 6 | if typing.TYPE_CHECKING: 7 | from django_copyist.copyist import ( 8 | CopyistConfig, 9 | IgnoredMap, 10 | OutputMap, 11 | SetToFilterMap, 12 | ) 13 | 14 | 15 | @dataclass 16 | class CopyRequest: 17 | """ 18 | This is the base class for a copy request, which serves as the input for the Copyist. 19 | 20 | :param input_data: The input data used to determine which models should be copied. 21 | It can also contain additional data that will be utilized during the copying process. 22 | :type input_data: dict[str, Any] 23 | :param config: An instance of CopyistConfig that holds all the 24 | necessary settings for the copying operation. 25 | :type config: CopyistConfig 26 | :param confirm_write: A flag indicating whether the copy operation should proceed even 27 | if there are unmatched values in the models or some models are ignored, defaults to False. 28 | :type confirm_write: bool, optional 29 | :param set_to_filter_map: A dictionary from the previous copy result that holds substitute data. 30 | If provided, the Copyist will compare it with the new set_to_filter_map 31 | to ensure that the data hasn't changed since the last copy attempt. 32 | If the data has changed, the Copyist will return a CopyResult with 33 | 'is_copy_successful' set to False and an updated set_to_filter_map, 34 | even if 'confirm_write' is True, defaults to None. 35 | :type set_to_filter_map: SetToFilterMap, optional 36 | :param ignored_map: A dictionary from the previous copy result that 37 | holds data of ignored models. If provided, the Copyist will compare 38 | it with the new ignored_map to ensure that the data hasn't changed 39 | since the last copy attempt. If the data has changed, 40 | the Copyist will return a CopyResult with 'is_copy_successful' 41 | set to False and an updated ignored_map, even if 'confirm_write' is True, defaults to None. 42 | :type ignored_map: IgnoredMap, optional 43 | """ 44 | 45 | input_data: dict[str, Any] 46 | config: "CopyistConfig" 47 | confirm_write: bool = False 48 | 49 | set_to_filter_map: Optional["SetToFilterMap"] = None 50 | ignored_map: Optional["IgnoredMap"] = None 51 | 52 | 53 | class AbortReason(StrEnum): 54 | # Copy aborted because of there are unmatched values in set_to_filter_map 55 | NOT_MATCHED = "NOT_MATCHED" 56 | # Copy aborted because of there are ignored models in ignored_map 57 | IGNORED = "IGNORED" 58 | # Copy aborted because of there are changes in set_to_filter_map 59 | DATA_CHANGED_STF = "DATA_CHANGED_STF" 60 | # Copy aborted because of there are changes in ignored_map 61 | DATA_CHANGED_IGNORED = "DATA_CHANGED_IGNORED" 62 | 63 | 64 | @dataclass 65 | class CopyResult: 66 | """ 67 | This is the base class for a copy result, which serves as the output for the Copyist. 68 | 69 | :ivar is_copy_successful: A flag indicating whether the copy operation was successful, 70 | defaults to False. 71 | :type is_copy_successful: bool, optional 72 | :ivar output_map: A dictionary that contains a mapping of model names to mappings of 73 | primary keys in the source and destination databases, defaults to None. 74 | :type output_map: dict, optional 75 | :ivar ignored_map: A dictionary that contains a mapping of model names to lists of 76 | primary keys of models that were ignored during the copying process, defaults to None. 77 | :type ignored_map: dict, optional 78 | :ivar set_to_filter_map: A dictionary that contains data of substitutes matched by 79 | the `SET_TO_FILTER` action. The structure is as follows: 80 | 81 | .. code-block:: python 82 | 83 | { 84 | "model_name": { 85 | "field_name": { 86 | "original_value": "new_value" | None 87 | } 88 | } 89 | } 90 | 91 | Defaults to None. 92 | :type set_to_filter_map: dict, optional 93 | :ivar reason: The reason code, returned if `is_copy_successful` is False, defaults to None. 94 | :type reason: AbortReason, optional 95 | """ 96 | 97 | is_copy_successful: bool 98 | output_map: Optional["OutputMap"] 99 | ignored_map: "IgnoredMap" 100 | set_to_filter_map: "SetToFilterMap" 101 | reason: AbortReason | None = None 102 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/_static/logo-no-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/docs/source/_static/logo-no-background.png -------------------------------------------------------------------------------- /docs/source/_static/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/docs/source/_static/logo.ico -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | Configuration 5 | ------------- 6 | 7 | .. automodule:: django_copyist.config 8 | :members: 9 | 10 | Copy request and result 11 | ----------------------- 12 | 13 | .. automodule:: django_copyist.copy_request 14 | :members: 15 | 16 | Copyist 17 | ------- 18 | 19 | .. automodule:: django_copyist.copyist 20 | :members: 21 | 22 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | import os 6 | import sys 7 | import tomllib 8 | 9 | # -- Project information ----------------------------------------------------- 10 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 11 | 12 | 13 | with open("../../pyproject.toml", "rb") as f: 14 | _META = tomllib.load(f) 15 | 16 | project = "django-copyist" 17 | copyright = "2024, abondar" 18 | author = "abondar" 19 | release = _META["tool"]["poetry"]["version"] 20 | 21 | # -- General configuration --------------------------------------------------- 22 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 23 | 24 | extensions = [ 25 | "sphinx.ext.autodoc", 26 | "enum_tools.autoenum", 27 | "sphinx_toolbox.more_autodoc.autoprotocol", 28 | ] 29 | 30 | templates_path = ["_templates"] 31 | exclude_patterns = [] 32 | 33 | # nitpicky = True 34 | 35 | html_favicon = os.path.join("_static", "logo.ico") 36 | html_logo = os.path.join("_static", "logo-no-background.png") 37 | 38 | # -- Options for HTML output ------------------------------------------------- 39 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 40 | 41 | html_theme = "sphinx_book_theme" 42 | html_static_path = ["_static"] 43 | 44 | # The master toctree document. 45 | master_doc = "toc" 46 | 47 | sys.path.insert(0, os.path.abspath("../..")) 48 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Copyist documentation master file, created by 2 | sphinx-quickstart on Sat Apr 27 13:24:54 2024. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | django-copyist 7 | ========================================== 8 | 9 | Tool for precise and efficient copying of Django models instances. 10 | 11 | Do you live in fear of the day when PM will come to you 12 | and ask to implement a copy feature for your root model, 13 | that has a lot of relations and you know that it will be a pain to implement it in a way that will work properly? 14 | 15 | Well, fear no more, as `django-copyist` got you covered 16 | 17 | 18 | Features 19 | -------- 20 | 21 | - **Precision** - Copy only what you need in a way that you need with help of custom copy actions. 22 | - **Readability** - With declarative style config - you can easily see what and how is copied, no need to hop between models and parse it in your head. 23 | - **Efficiency** - Unlike some other solutions and naive approaches, copyist is copying your data without recursive hopping between model instances, which gives it a magnitudes of speedup on big data sets. 24 | - **Flexibility** - Copyist covers all steps that are there for data copy, including validation of data, pre-copy and post-copy actions. Copyist also work good with de-normalized data, not judging you for your choices. 25 | - **Interactive** - Copyist provides a way to interact with the copy process, allowing application to see what exactly is going to be done, and propagate that info to end user to decide if he wants to go through with it. 26 | - **Reusability** - With copyist your copy flow is not nailed down to model, allowing you defining different approaches for same model, and at the same time reuse existing configurations. 27 | 28 | Motivation 29 | ---------- 30 | 31 | This project was build as in-house tool for project with complex hierarchy of models, 32 | where we needed to copy them in a very particular way. 33 | 34 | Existing solutions like `django-clone `_ were designed 35 | in a way that didn't fit our needs, as they required to modify models and 36 | didn't allow to have full control over the copying process. 37 | 38 | This project aims to provide a more flexible and efficient way to copy Django models instances, while 39 | not affecting existing codebase. 40 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | This pages aims to get you going with django-copyist as quickly as possible. 5 | 6 | Installation 7 | ------------ 8 | 9 | To install with pip: 10 | 11 | .. code-block:: console 12 | 13 | pip install django-copyist 14 | 15 | To install with poetry: 16 | 17 | .. code-block:: console 18 | 19 | poetry add django-copyist 20 | 21 | 22 | Basic usage 23 | ----------- 24 | 25 | Assuming you have following models in your Django app: 26 | 27 | .. code-block:: python 28 | 29 | from django.db import models 30 | 31 | 32 | class Company(models.Model): 33 | name = models.CharField(max_length=100) 34 | address = models.CharField(max_length=100) 35 | 36 | 37 | class Project(models.Model): 38 | name = models.CharField(max_length=100) 39 | company = models.ForeignKey( 40 | Company, related_name="projects", on_delete=models.CASCADE 41 | ) 42 | 43 | 44 | class Employee(models.Model): 45 | name = models.CharField(max_length=100) 46 | company = models.ForeignKey( 47 | Company, related_name="employees", on_delete=models.CASCADE 48 | ) 49 | 50 | 51 | class Counterpart(models.Model): 52 | name = models.CharField(max_length=100) 53 | external_id = models.IntegerField() 54 | project = models.ForeignKey( 55 | Project, related_name="counterparts", on_delete=models.CASCADE 56 | ) 57 | 58 | 59 | class Task(models.Model): 60 | name = models.CharField(max_length=100) 61 | description = models.TextField() 62 | 63 | assignee = models.ForeignKey( 64 | Employee, related_name="tasks", on_delete=models.CASCADE 65 | ) 66 | project = models.ForeignKey(Project, related_name="tasks", on_delete=models.CASCADE) 67 | counterparts = models.ManyToManyField(Counterpart, related_name="tasks") 68 | 69 | 70 | And you want to create full copy of company with all nested data, but also want it to be created with different name and address. 71 | In this case you should write following :py:class:`~.django_copyist.config.ModelCopyConfig` 72 | 73 | .. code-block:: python 74 | 75 | from django_copyist.config import ( 76 | ModelCopyConfig, 77 | TAKE_FROM_ORIGIN, 78 | MakeCopy, 79 | UpdateToCopied, 80 | FieldCopyConfig, 81 | CopyActions, 82 | ) 83 | from example.demo.models import ( 84 | Project, 85 | Counterpart, 86 | Task, 87 | Company, 88 | Employee, 89 | ) 90 | 91 | 92 | config = ModelCopyConfig( 93 | model=Company, 94 | filter_field_to_input_key={"id": "company_id"}, 95 | field_copy_actions={ 96 | "name": FieldCopyConfig( 97 | action=CopyActions.TAKE_FROM_INPUT, 98 | input_key="new_company_name", 99 | ), 100 | "address": FieldCopyConfig( 101 | action=CopyActions.TAKE_FROM_INPUT, 102 | input_key="new_company_address", 103 | ), 104 | "projects": MakeCopy( 105 | ModelCopyConfig( 106 | model=Project, 107 | field_copy_actions={ 108 | "name": TAKE_FROM_ORIGIN, 109 | "counterparts": MakeCopy( 110 | ModelCopyConfig( 111 | model=Counterpart, 112 | field_copy_actions={ 113 | "name": TAKE_FROM_ORIGIN, 114 | "external_id": TAKE_FROM_ORIGIN, 115 | }, 116 | ) 117 | ), 118 | }, 119 | ) 120 | ), 121 | "employees": MakeCopy( 122 | ModelCopyConfig( 123 | model=Employee, 124 | field_copy_actions={ 125 | "name": TAKE_FROM_ORIGIN, 126 | }, 127 | ) 128 | ), 129 | }, 130 | compound_copy_actions=[ 131 | ModelCopyConfig( 132 | model=Task, 133 | field_copy_actions={ 134 | "name": TAKE_FROM_ORIGIN, 135 | "description": TAKE_FROM_ORIGIN, 136 | "counterparts": UpdateToCopied(Counterpart), 137 | "project": UpdateToCopied(Project), 138 | "assignee": UpdateToCopied(Employee), 139 | }, 140 | ) 141 | ], 142 | ) 143 | 144 | And then you can execute copy action like this: 145 | 146 | .. code-block:: python 147 | 148 | from django_copyist.copy_request import CopyRequest 149 | from django_copyist.copyist import CopyistConfig, Copyist 150 | 151 | copy_request = CopyRequest( 152 | config=CopyistConfig([config]), 153 | input_data={ 154 | "company_id": company_id, 155 | "new_company_name": new_company_name, 156 | "new_company_address": new_company_address, 157 | }, 158 | confirm_write=False, 159 | ) 160 | result = Copyist(copy_request).execute_copy_request() 161 | 162 | With this, all company data should be copied. 163 | That seems like a lot to take in, so let's break it down to what exactly happens here: 164 | 165 | 1. We define a :py:class:`~.django_copyist.config.ModelCopyConfig` for the `Company` model. 166 | 167 | .. code-block:: python 168 | 169 | config = ModelCopyConfig( 170 | model=Company, 171 | filter_field_to_input_key={"id": "company_id"}, 172 | ... 173 | 174 | :py:class:`~.django_copyist.config.ModelCopyConfig` is a class that defines how to copy a model. It takes the model class as the first argument and a dictionary that maps the filter field to the input key. This is used to find the object to copy. 175 | 176 | 2. Next we define :py:attr:`.ModelCopyConfig.field_copy_actions` for the `Company` model. 177 | 178 | .. code-block:: python 179 | 180 | field_copy_actions={ 181 | "name": FieldCopyConfig( 182 | action=CopyActions.TAKE_FROM_INPUT, 183 | input_key="new_company_name", 184 | ), 185 | "address": FieldCopyConfig( 186 | action=CopyActions.TAKE_FROM_INPUT, 187 | input_key="new_company_address", 188 | ), 189 | "projects": MakeCopy( 190 | ModelCopyConfig( 191 | model=Project, 192 | field_copy_actions={ 193 | "name": TAKE_FROM_ORIGIN, 194 | "counterparts": MakeCopy( 195 | ModelCopyConfig( 196 | model=Counterpart, 197 | field_copy_actions={ 198 | "name": TAKE_FROM_ORIGIN, 199 | "external_id": TAKE_FROM_ORIGIN, 200 | }, 201 | ) 202 | ), 203 | }, 204 | ) 205 | ), 206 | "employees": MakeCopy( 207 | ModelCopyConfig( 208 | model=Employee, 209 | field_copy_actions={ 210 | "name": TAKE_FROM_ORIGIN, 211 | }, 212 | ) 213 | ), 214 | ... 215 | 216 | :py:attr:`.ModelCopyConfig.field_copy_actions` is a dictionary that maps the field name to a :py:class:`~.FieldCopyConfig` object. 217 | 218 | The :py:class:`~.FieldCopyConfig` object defines how to copy the field. In this case, we take the `name` and `address` fields from the input data. 219 | 220 | :py:attr:`~django_copyist.config.TAKE_FROM_ORIGIN` is a shortcut for creating :py:class:`~.FieldCopyConfig` with :py:attr:`~.CopyActions.TAKE_FROM_ORIGIN` action, which takes value for new object from original object. 221 | 222 | We also define how to copy the `projects` and `employees` fields. 223 | 224 | We use the :py:attr:`~.MakeCopy` action to copy the related objects. 225 | :py:attr:`~.MakeCopy` is a shortcut for creating :py:class:`~.FieldCopyConfig` with :py:attr:`CopyActions.MAKE_COPY` action and reference to given model. 226 | Nested :py:attr:`~.MakeCopy` automatically propagate parent id to child object. 227 | 228 | 3. We define :py:attr:`~.ModelCopyConfig.compound_copy_actions` for the `Company` model. 229 | 230 | .. code-block:: python 231 | 232 | compound_copy_actions=[ 233 | ModelCopyConfig( 234 | model=Task, 235 | field_copy_actions={ 236 | "name": TAKE_FROM_ORIGIN, 237 | "description": TAKE_FROM_ORIGIN, 238 | "counterparts": UpdateToCopied(Counterpart), 239 | "project": UpdateToCopied(Project), 240 | "assignee": UpdateToCopied(Employee), 241 | }, 242 | ) 243 | ... 244 | 245 | :py:attr:`~.ModelCopyConfig.compound_copy_actions` is a list of :py:class:`~.ModelCopyConfig` objects that define how 246 | to copy related objects that are not directly related to the model, or related through multiple relations that need to be created beforehand. 247 | 248 | :py:attr:`~.ModelCopyConfig.compound_copy_actions` are executed after all fields are copied. 249 | 250 | In this case, we define how to copy the `Task` model. We take the `name` and `description` fields from the original object. We also define how to copy the `counterparts`, `project`, and `assignee` fields. 251 | 252 | :py:func:`~.UpdateToCopied` is a shortcut for creating :py:class:`~.FieldCopyConfig` with :py:attr:`CopyActions.UPDATE_TO_COPIED` action and reference to given model. 253 | It will search mapping of previously copied objects and update reference to copied object. 254 | 255 | 4. We create a :py:class:`~.CopyRequest` object with the :py:class:`~.CopyistConfig` and input data. 256 | 257 | .. code-block:: python 258 | 259 | copy_request = CopyRequest( 260 | config=CopyistConfig([config]), 261 | input_data={ 262 | "company_id": company_id, 263 | "new_company_name": new_company_name, 264 | "new_company_address": new_company_address, 265 | }, 266 | confirm_write=False, 267 | ) 268 | ... 269 | 270 | :py:class:`~.CopyRequest` is a class that defines the copy request. It takes the `CopyistConfig` object, input data, and a boolean flag that indicates whether to confirm the write operation. 271 | 272 | :py:class:`~.CopyistConfig` is a class that defines the configuration for the copy operation. It takes a list of :py:class:`~.ModelCopyConfig` objects. 273 | 274 | :py:attr:`.CopyResult.input_data` is a dictionary that contains the input data for the copy operation. It is later used in filtering or :py:attr:`~.TAKE_FROM_INPUT` actions. 275 | 276 | :py:attr:`.CopyResult.confirm_write` is a boolean flag that indicates whether to confirm the write operation, 277 | even if there are issues with matching objects in origin location with objects in target destination. 278 | It is not used in this example, but you can read more about it in overview section of this documentation. 279 | 280 | 5. We execute the copy request. 281 | 282 | .. code-block:: python 283 | 284 | result = Copyist(copy_request).execute_copy_request() 285 | 286 | :py:class:`~django_copyist.copyist.Copyist` is a class that executes the copy request. It takes the :py:class:`~.CopyRequest` object as an argument. 287 | 288 | :py:attr:`.CopyResult.execute_copy_request` method returns :py:class:`~.CopyResult` object that contains information about the copy operation. Read more about it in overview section. 289 | 290 | And like this you have copied the company with all related data and can see and edit configuration in one place. 291 | 292 | Next steps 293 | ---------- 294 | 295 | This is just a basic example of how to use django-copyist. 296 | It can do much more granular control on how it should execute copy, and you can read more about it in the documentation. 297 | -------------------------------------------------------------------------------- /docs/source/toc.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :caption: Contents 3 | :maxdepth: 4 4 | :includehidden: 5 | 6 | index 7 | quickstart 8 | overview 9 | api -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/example/__init__.py -------------------------------------------------------------------------------- /example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/example/demo/__init__.py -------------------------------------------------------------------------------- /example/demo/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DemoConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "example.demo" 7 | -------------------------------------------------------------------------------- /example/demo/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.4 on 2024-04-27 14:57 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Company", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=100)), 27 | ("address", models.CharField(max_length=100)), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name="Employee", 32 | fields=[ 33 | ( 34 | "id", 35 | models.BigAutoField( 36 | auto_created=True, 37 | primary_key=True, 38 | serialize=False, 39 | verbose_name="ID", 40 | ), 41 | ), 42 | ("name", models.CharField(max_length=100)), 43 | ( 44 | "company", 45 | models.ForeignKey( 46 | on_delete=django.db.models.deletion.CASCADE, 47 | related_name="employees", 48 | to="demo.company", 49 | ), 50 | ), 51 | ], 52 | ), 53 | migrations.CreateModel( 54 | name="Project", 55 | fields=[ 56 | ( 57 | "id", 58 | models.BigAutoField( 59 | auto_created=True, 60 | primary_key=True, 61 | serialize=False, 62 | verbose_name="ID", 63 | ), 64 | ), 65 | ("name", models.CharField(max_length=100)), 66 | ( 67 | "company", 68 | models.ForeignKey( 69 | on_delete=django.db.models.deletion.CASCADE, 70 | related_name="projects", 71 | to="demo.company", 72 | ), 73 | ), 74 | ], 75 | ), 76 | migrations.CreateModel( 77 | name="Counterpart", 78 | fields=[ 79 | ( 80 | "id", 81 | models.BigAutoField( 82 | auto_created=True, 83 | primary_key=True, 84 | serialize=False, 85 | verbose_name="ID", 86 | ), 87 | ), 88 | ("name", models.CharField(max_length=100)), 89 | ("external_id", models.IntegerField()), 90 | ( 91 | "project", 92 | models.ForeignKey( 93 | on_delete=django.db.models.deletion.CASCADE, 94 | related_name="counterparts", 95 | to="demo.project", 96 | ), 97 | ), 98 | ], 99 | ), 100 | migrations.CreateModel( 101 | name="Task", 102 | fields=[ 103 | ( 104 | "id", 105 | models.BigAutoField( 106 | auto_created=True, 107 | primary_key=True, 108 | serialize=False, 109 | verbose_name="ID", 110 | ), 111 | ), 112 | ("name", models.CharField(max_length=100)), 113 | ("description", models.TextField()), 114 | ( 115 | "assignee", 116 | models.ForeignKey( 117 | on_delete=django.db.models.deletion.CASCADE, 118 | related_name="tasks", 119 | to="demo.employee", 120 | ), 121 | ), 122 | ( 123 | "counterparts", 124 | models.ManyToManyField(related_name="tasks", to="demo.counterpart"), 125 | ), 126 | ( 127 | "project", 128 | models.ForeignKey( 129 | on_delete=django.db.models.deletion.CASCADE, 130 | related_name="tasks", 131 | to="demo.project", 132 | ), 133 | ), 134 | ], 135 | ), 136 | ] 137 | -------------------------------------------------------------------------------- /example/demo/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/example/demo/migrations/__init__.py -------------------------------------------------------------------------------- /example/demo/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Company(models.Model): 5 | name = models.CharField(max_length=100) 6 | address = models.CharField(max_length=100) 7 | 8 | 9 | class Project(models.Model): 10 | name = models.CharField(max_length=100) 11 | company = models.ForeignKey( 12 | Company, related_name="projects", on_delete=models.CASCADE 13 | ) 14 | 15 | 16 | class Employee(models.Model): 17 | name = models.CharField(max_length=100) 18 | company = models.ForeignKey( 19 | Company, related_name="employees", on_delete=models.CASCADE 20 | ) 21 | 22 | 23 | class Counterpart(models.Model): 24 | name = models.CharField(max_length=100) 25 | external_id = models.IntegerField() 26 | project = models.ForeignKey( 27 | Project, related_name="counterparts", on_delete=models.CASCADE 28 | ) 29 | 30 | 31 | class Task(models.Model): 32 | name = models.CharField(max_length=100) 33 | description = models.TextField() 34 | 35 | assignee = models.ForeignKey( 36 | Employee, related_name="tasks", on_delete=models.CASCADE 37 | ) 38 | project = models.ForeignKey(Project, related_name="tasks", on_delete=models.CASCADE) 39 | counterparts = models.ManyToManyField(Counterpart, related_name="tasks") 40 | -------------------------------------------------------------------------------- /example/demo/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/example/demo/tests/__init__.py -------------------------------------------------------------------------------- /example/demo/tests/test_examples.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | import pytest 4 | from django.db.models import Model, Q 5 | 6 | from django_copyist.config import ( 7 | TAKE_FROM_ORIGIN, 8 | CopyActions, 9 | DataModificationActions, 10 | DataPreparationStep, 11 | FieldCopyConfig, 12 | FieldFilterConfig, 13 | FilterConfig, 14 | FilterSource, 15 | IgnoreCondition, 16 | IgnoreFilter, 17 | MakeCopy, 18 | ModelCopyConfig, 19 | PostcopyStep, 20 | UpdateToCopied, 21 | ) 22 | from django_copyist.copy_request import AbortReason, CopyRequest 23 | from django_copyist.copyist import ( 24 | CopyIntent, 25 | Copyist, 26 | CopyistConfig, 27 | FieldSetToFilterMap, 28 | IgnoredMap, 29 | OutputMap, 30 | SetToFilterMap, 31 | ) 32 | from example.demo.models import Company, Counterpart, Employee, Project, Task 33 | 34 | 35 | @pytest.mark.django_db 36 | def test_copy_diamond_hierarchy(): 37 | company = Company.objects.create(name="Company", address="Address") 38 | project = Project.objects.create(name="Project", company=company) 39 | counterpart = Counterpart.objects.create( 40 | name="Counterpart", external_id=1, project=project 41 | ) 42 | counterpart2 = Counterpart.objects.create( 43 | name="Counterpart2", external_id=2, project=project 44 | ) 45 | 46 | employee = Employee.objects.create(name="Employee", company=company) 47 | task = Task.objects.create( 48 | name="Task", 49 | description="Description", 50 | assignee=employee, 51 | project=project, 52 | ) 53 | task.counterparts.add(counterpart, counterpart2) 54 | 55 | config = ModelCopyConfig( 56 | model=Company, 57 | filter_field_to_input_key={"id": "company_id"}, 58 | field_copy_actions={ 59 | "name": FieldCopyConfig( 60 | action=CopyActions.TAKE_FROM_INPUT, 61 | input_key="new_company_name", 62 | ), 63 | "address": FieldCopyConfig( 64 | action=CopyActions.TAKE_FROM_INPUT, 65 | input_key="new_company_address", 66 | ), 67 | "projects": MakeCopy( 68 | ModelCopyConfig( 69 | model=Project, 70 | field_copy_actions={ 71 | "name": TAKE_FROM_ORIGIN, 72 | "counterparts": MakeCopy( 73 | ModelCopyConfig( 74 | model=Counterpart, 75 | field_copy_actions={ 76 | "name": TAKE_FROM_ORIGIN, 77 | "external_id": TAKE_FROM_ORIGIN, 78 | }, 79 | ) 80 | ), 81 | }, 82 | ) 83 | ), 84 | "employees": MakeCopy( 85 | ModelCopyConfig( 86 | model=Employee, 87 | field_copy_actions={ 88 | "name": TAKE_FROM_ORIGIN, 89 | }, 90 | ) 91 | ), 92 | }, 93 | compound_copy_actions=[ 94 | ModelCopyConfig( 95 | model=Task, 96 | field_copy_actions={ 97 | "name": TAKE_FROM_ORIGIN, 98 | "description": TAKE_FROM_ORIGIN, 99 | "counterparts": UpdateToCopied(Counterpart), 100 | "project": UpdateToCopied(Project), 101 | "assignee": UpdateToCopied(Employee), 102 | }, 103 | ) 104 | ], 105 | ) 106 | 107 | new_company_name = "New Company" 108 | new_company_address = "New Address" 109 | copy_request = CopyRequest( 110 | config=CopyistConfig([config]), 111 | input_data={ 112 | "company_id": company.id, 113 | "new_company_name": new_company_name, 114 | "new_company_address": new_company_address, 115 | }, 116 | confirm_write=False, 117 | ) 118 | result = Copyist(copy_request).execute_copy_request() 119 | 120 | assert result.is_copy_successful 121 | assert len(result.output_map["Company"]) == 1 122 | 123 | company_copy_id = result.output_map["Company"][str(company.id)] 124 | new_company = Company.objects.get(id=company_copy_id) 125 | assert new_company.name == new_company_name 126 | assert new_company.address == new_company_address 127 | assert new_company.id != company.id 128 | 129 | assert len(result.output_map["Project"]) == 1 130 | project_copy_id = result.output_map["Project"][str(project.id)] 131 | new_project = Project.objects.get(id=project_copy_id) 132 | assert new_project.name == project.name 133 | assert new_project.company_id == new_company.id 134 | assert new_project.id != project.id 135 | 136 | assert len(result.output_map["Counterpart"]) == 2 137 | new_counterparts = Counterpart.objects.filter(project_id=new_project.id) 138 | assert len(new_counterparts) == 2 139 | 140 | assert not ( 141 | {counterpart.id, counterpart2.id} 142 | & {new_counterpart.id for new_counterpart in new_counterparts} 143 | ) 144 | 145 | assert len(result.output_map["Employee"]) == 1 146 | employee_copy_id = result.output_map["Employee"][str(employee.id)] 147 | new_employee = Employee.objects.get(id=employee_copy_id) 148 | assert new_employee.name == employee.name 149 | assert new_employee.company_id == new_company.id 150 | assert new_employee.id != employee.id 151 | 152 | assert len(result.output_map["Task"]) == 1 153 | task_copy_id = result.output_map["Task"][str(task.id)] 154 | new_task = Task.objects.get(id=task_copy_id) 155 | assert new_task.name == task.name 156 | assert new_task.description == task.description 157 | 158 | assert new_task.assignee_id == new_employee.id 159 | assert new_task.project_id == new_project.id 160 | assert new_task.id != task.id 161 | 162 | assert new_task.counterparts.count() == 2 163 | assert set(new_task.counterparts.values_list("id", flat=True)) == { 164 | new_counterpart.id for new_counterpart in new_counterparts 165 | } 166 | 167 | 168 | @pytest.mark.django_db 169 | def test_take_from_origin(): 170 | company = Company.objects.create(name="Company", address="Address") 171 | 172 | config = ModelCopyConfig( 173 | model=Company, 174 | filter_field_to_input_key={"id": "company_id"}, 175 | field_copy_actions={ 176 | "name": TAKE_FROM_ORIGIN, 177 | "address": TAKE_FROM_ORIGIN, 178 | }, 179 | ) 180 | copy_request = CopyRequest( 181 | config=CopyistConfig([config]), 182 | input_data={"company_id": company.id}, 183 | confirm_write=False, 184 | ) 185 | result = Copyist(copy_request).execute_copy_request() 186 | assert result.is_copy_successful 187 | assert len(result.output_map["Company"]) == 1 188 | new_company_id = result.output_map["Company"][str(company.id)] 189 | new_company = Company.objects.get(id=new_company_id) 190 | assert new_company.name == company.name 191 | 192 | 193 | @pytest.mark.django_db 194 | def test_take_from_input(): 195 | company = Company.objects.create(name="Company", address="Address") 196 | 197 | config = ModelCopyConfig( 198 | model=Company, 199 | filter_field_to_input_key={"id": "company_id"}, 200 | field_copy_actions={ 201 | "name": FieldCopyConfig( 202 | action=CopyActions.TAKE_FROM_INPUT, input_key="new_name" 203 | ), 204 | "address": TAKE_FROM_ORIGIN, 205 | }, 206 | ) 207 | copy_request = CopyRequest( 208 | config=CopyistConfig([config]), 209 | input_data={"company_id": company.id, "new_name": "New Company"}, 210 | confirm_write=False, 211 | ) 212 | result = Copyist(copy_request).execute_copy_request() 213 | assert result.is_copy_successful 214 | assert len(result.output_map["Company"]) == 1 215 | new_company_id = result.output_map["Company"][str(company.id)] 216 | new_company = Company.objects.get(id=new_company_id) 217 | assert new_company.name == "New Company" 218 | 219 | 220 | @pytest.mark.django_db 221 | def test_set_to_filter(): 222 | company = Company.objects.create(name="Company", address="Address") 223 | project1 = Project.objects.create(name="Project1", company=company) 224 | project2 = Project.objects.create(name="Project2", company=company) 225 | counterpart1 = Counterpart.objects.create( 226 | name="Counterpart", external_id=1, project=project1 227 | ) 228 | counterpart2 = Counterpart.objects.create( 229 | name="Counterpart", external_id=1, project=project2 230 | ) 231 | employee = Employee.objects.create(name="Employee", company=company) 232 | task = Task.objects.create( 233 | name="Task", 234 | description="Description", 235 | assignee=employee, 236 | project=project1, 237 | ) 238 | task.counterparts.add(counterpart1) 239 | 240 | config = ModelCopyConfig( 241 | model=Employee, 242 | filter_field_to_input_key={"id": "employee_id"}, 243 | field_copy_actions={ 244 | "name": TAKE_FROM_ORIGIN, 245 | "company": TAKE_FROM_ORIGIN, 246 | "tasks": MakeCopy( 247 | ModelCopyConfig( 248 | model=Task, 249 | field_copy_actions={ 250 | "name": TAKE_FROM_ORIGIN, 251 | "description": TAKE_FROM_ORIGIN, 252 | "project_id": FieldCopyConfig( 253 | action=CopyActions.TAKE_FROM_INPUT, 254 | input_key="new_project_id", 255 | ), 256 | "counterparts": FieldCopyConfig( 257 | action=CopyActions.SET_TO_FILTER, 258 | reference_to=Counterpart, 259 | filter_config=FilterConfig( 260 | filters={ 261 | "project_id": FieldFilterConfig( 262 | source=FilterSource.FROM_INPUT, 263 | key="new_project_id", 264 | ), 265 | "external_id": FieldFilterConfig( 266 | source=FilterSource.FROM_ORIGIN 267 | ), 268 | } 269 | ), 270 | ), 271 | }, 272 | ) 273 | ), 274 | }, 275 | ) 276 | 277 | result = Copyist( 278 | CopyRequest( 279 | config=CopyistConfig([config]), 280 | input_data={ 281 | "employee_id": employee.id, 282 | "new_project_id": project2.id, 283 | }, 284 | confirm_write=False, 285 | ) 286 | ).execute_copy_request() 287 | 288 | assert result.is_copy_successful 289 | assert len(result.output_map["Employee"]) == 1 290 | assert len(result.output_map["Task"]) == 1 291 | 292 | new_task_id = result.output_map["Task"][str(task.id)] 293 | new_task = Task.objects.get(id=new_task_id) 294 | assert new_task.project_id == project2.id 295 | assert new_task.counterparts.count() == 1 296 | new_counterpart = new_task.counterparts.first() 297 | assert new_counterpart.project_id == project2.id 298 | assert new_counterpart.external_id == counterpart2.external_id 299 | assert new_counterpart.id != counterpart1.id 300 | assert new_counterpart.id == counterpart2.id 301 | 302 | 303 | @pytest.mark.django_db 304 | def test_set_to_filter_not_found(): 305 | company = Company.objects.create(name="Company", address="Address") 306 | project1 = Project.objects.create(name="Project1", company=company) 307 | project2 = Project.objects.create(name="Project2", company=company) 308 | counterpart1 = Counterpart.objects.create( 309 | name="Counterpart", external_id=1, project=project1 310 | ) 311 | counterpart2 = Counterpart.objects.create( 312 | name="Counterpart 2", external_id=2, project=project1 313 | ) 314 | counterpart3 = Counterpart.objects.create( 315 | name="Counterpart", external_id=1, project=project2 316 | ) 317 | employee = Employee.objects.create(name="Employee", company=company) 318 | task = Task.objects.create( 319 | name="Task", 320 | description="Description", 321 | assignee=employee, 322 | project=project1, 323 | ) 324 | task.counterparts.add(counterpart1, counterpart2) 325 | 326 | config = ModelCopyConfig( 327 | model=Employee, 328 | filter_field_to_input_key={"id": "employee_id"}, 329 | field_copy_actions={ 330 | "name": TAKE_FROM_ORIGIN, 331 | "company": TAKE_FROM_ORIGIN, 332 | "tasks": MakeCopy( 333 | ModelCopyConfig( 334 | model=Task, 335 | field_copy_actions={ 336 | "name": TAKE_FROM_ORIGIN, 337 | "description": TAKE_FROM_ORIGIN, 338 | "project_id": FieldCopyConfig( 339 | action=CopyActions.TAKE_FROM_INPUT, 340 | input_key="new_project_id", 341 | ), 342 | "counterparts": FieldCopyConfig( 343 | action=CopyActions.SET_TO_FILTER, 344 | reference_to=Counterpart, 345 | filter_config=FilterConfig( 346 | filters={ 347 | "project_id": FieldFilterConfig( 348 | source=FilterSource.FROM_INPUT, 349 | key="new_project_id", 350 | ), 351 | "external_id": FieldFilterConfig( 352 | source=FilterSource.FROM_ORIGIN 353 | ), 354 | } 355 | ), 356 | ), 357 | }, 358 | ) 359 | ), 360 | }, 361 | ) 362 | 363 | result = Copyist( 364 | CopyRequest( 365 | config=CopyistConfig([config]), 366 | input_data={ 367 | "employee_id": employee.id, 368 | "new_project_id": project2.id, 369 | }, 370 | confirm_write=False, 371 | ) 372 | ).execute_copy_request() 373 | 374 | assert not result.is_copy_successful 375 | assert result.reason == AbortReason.NOT_MATCHED 376 | assert result.set_to_filter_map[Task.__name__]["counterparts"] == { 377 | str(counterpart1.id): str(counterpart3.id), 378 | str(counterpart2.id): None, 379 | } 380 | 381 | result = Copyist( 382 | CopyRequest( 383 | config=CopyistConfig([config]), 384 | input_data={ 385 | "employee_id": employee.id, 386 | "new_project_id": project2.id, 387 | }, 388 | confirm_write=True, 389 | set_to_filter_map=result.set_to_filter_map, 390 | ignored_map=result.ignored_map, 391 | ) 392 | ).execute_copy_request() 393 | 394 | assert result.is_copy_successful 395 | 396 | new_task_id = result.output_map["Task"][str(task.id)] 397 | new_task = Task.objects.get(id=new_task_id) 398 | assert new_task.counterparts.count() == 1 399 | 400 | 401 | @pytest.mark.django_db 402 | def test_set_to_filter_by_func(): 403 | company = Company.objects.create(name="Company", address="Address") 404 | project1 = Project.objects.create(name="Project1", company=company) 405 | project2 = Project.objects.create(name="Project2", company=company) 406 | counterpart1 = Counterpart.objects.create( 407 | name="Counterpart", external_id=1, project=project1 408 | ) 409 | counterpart2 = Counterpart.objects.create( 410 | name="Counterpart", external_id=1, project=project2 411 | ) 412 | employee = Employee.objects.create(name="Employee", company=company) 413 | task = Task.objects.create( 414 | name="Task", 415 | description="Description", 416 | assignee=employee, 417 | project=project1, 418 | ) 419 | task.counterparts.add(counterpart1) 420 | 421 | def match_counterparts( 422 | model_config: "ModelCopyConfig", 423 | input_data: dict[str, Any], 424 | field_name: str, 425 | field_copy_config: "FieldCopyConfig", 426 | set_to_filter_map: "SetToFilterMap", 427 | instance_list: list[Model], 428 | referenced_instance_list: list[Model], 429 | ) -> "FieldSetToFilterMap": 430 | original_counterparts = Counterpart.objects.filter( 431 | tasks__id__in=[task.id for task in instance_list], 432 | ) 433 | new_counterparts = Counterpart.objects.filter( 434 | project_id=input_data["new_project_id"], 435 | external_id__in=[cp.external_id for cp in original_counterparts], 436 | ) 437 | external_id_to_new_counterpart = {cp.external_id: cp for cp in new_counterparts} 438 | return { 439 | str(cp.id): ( 440 | str(external_id_to_new_counterpart[cp.external_id].id) 441 | if cp.external_id in external_id_to_new_counterpart 442 | else None 443 | ) 444 | for cp in original_counterparts 445 | } 446 | 447 | config = ModelCopyConfig( 448 | model=Employee, 449 | filter_field_to_input_key={"id": "employee_id"}, 450 | field_copy_actions={ 451 | "name": TAKE_FROM_ORIGIN, 452 | "company": TAKE_FROM_ORIGIN, 453 | "tasks": MakeCopy( 454 | ModelCopyConfig( 455 | model=Task, 456 | field_copy_actions={ 457 | "name": TAKE_FROM_ORIGIN, 458 | "description": TAKE_FROM_ORIGIN, 459 | "project_id": FieldCopyConfig( 460 | action=CopyActions.TAKE_FROM_INPUT, 461 | input_key="new_project_id", 462 | ), 463 | "counterparts": FieldCopyConfig( 464 | action=CopyActions.SET_TO_FILTER, 465 | reference_to=Counterpart, 466 | filter_config=FilterConfig( 467 | filter_func=match_counterparts, 468 | ), 469 | ), 470 | }, 471 | ) 472 | ), 473 | }, 474 | ) 475 | 476 | result = Copyist( 477 | CopyRequest( 478 | config=CopyistConfig([config]), 479 | input_data={ 480 | "employee_id": employee.id, 481 | "new_project_id": project2.id, 482 | }, 483 | confirm_write=False, 484 | ) 485 | ).execute_copy_request() 486 | 487 | assert result.is_copy_successful 488 | assert len(result.output_map["Employee"]) == 1 489 | assert len(result.output_map["Task"]) == 1 490 | 491 | new_task_id = result.output_map["Task"][str(task.id)] 492 | new_task = Task.objects.get(id=new_task_id) 493 | assert new_task.project_id == project2.id 494 | assert new_task.counterparts.count() == 1 495 | new_counterpart = new_task.counterparts.first() 496 | assert new_counterpart.project_id == project2.id 497 | assert new_counterpart.external_id == counterpart2.external_id 498 | assert new_counterpart.id != counterpart1.id 499 | assert new_counterpart.id == counterpart2.id 500 | 501 | 502 | @pytest.mark.django_db 503 | def test_ignore_condition(): 504 | company = Company.objects.create(name="Company", address="Address") 505 | project1 = Project.objects.create(name="Project1", company=company) 506 | project2 = Project.objects.create(name="Project2", company=company) 507 | counterpart1 = Counterpart.objects.create( 508 | name="Counterpart", external_id=1, project=project1 509 | ) 510 | counterpart2 = Counterpart.objects.create( 511 | name="Counterpart 2", external_id=2, project=project1 512 | ) 513 | counterpart3 = Counterpart.objects.create( 514 | name="Counterpart", external_id=1, project=project2 515 | ) 516 | employee = Employee.objects.create(name="Employee", company=company) 517 | task1 = Task.objects.create( 518 | name="Task", 519 | description="Description", 520 | assignee=employee, 521 | project=project1, 522 | ) 523 | task1.counterparts.add(counterpart1, counterpart2) 524 | task2 = Task.objects.create( 525 | name="Task 2", 526 | description="Description", 527 | assignee=employee, 528 | project=project1, 529 | ) 530 | task2.counterparts.add(counterpart1) 531 | 532 | config = ModelCopyConfig( 533 | model=Employee, 534 | filter_field_to_input_key={"id": "employee_id"}, 535 | field_copy_actions={ 536 | "name": TAKE_FROM_ORIGIN, 537 | "company": TAKE_FROM_ORIGIN, 538 | "tasks": MakeCopy( 539 | ModelCopyConfig( 540 | model=Task, 541 | ignore_condition=IgnoreCondition( 542 | filter_conditions=[ 543 | IgnoreFilter( 544 | filter_name="counterparts__id__in", 545 | set_to_filter_field_name="counterparts", 546 | set_to_filter_origin_model=Task, 547 | ) 548 | ] 549 | ), 550 | field_copy_actions={ 551 | "name": TAKE_FROM_ORIGIN, 552 | "description": TAKE_FROM_ORIGIN, 553 | "project_id": FieldCopyConfig( 554 | action=CopyActions.TAKE_FROM_INPUT, 555 | input_key="new_project_id", 556 | ), 557 | "counterparts": FieldCopyConfig( 558 | action=CopyActions.SET_TO_FILTER, 559 | reference_to=Counterpart, 560 | filter_config=FilterConfig( 561 | filters={ 562 | "project_id": FieldFilterConfig( 563 | source=FilterSource.FROM_INPUT, 564 | key="new_project_id", 565 | ), 566 | "external_id": FieldFilterConfig( 567 | source=FilterSource.FROM_ORIGIN 568 | ), 569 | } 570 | ), 571 | ), 572 | }, 573 | ) 574 | ), 575 | }, 576 | ) 577 | 578 | result = Copyist( 579 | CopyRequest( 580 | config=CopyistConfig([config]), 581 | input_data={ 582 | "employee_id": employee.id, 583 | "new_project_id": project2.id, 584 | }, 585 | confirm_write=False, 586 | ) 587 | ).execute_copy_request() 588 | 589 | assert not result.is_copy_successful 590 | assert result.reason == AbortReason.IGNORED 591 | 592 | assert result.ignored_map[Task.__name__] == [task1.id] 593 | 594 | result = Copyist( 595 | CopyRequest( 596 | config=CopyistConfig([config]), 597 | input_data={ 598 | "employee_id": employee.id, 599 | "new_project_id": project2.id, 600 | }, 601 | confirm_write=True, 602 | set_to_filter_map=result.set_to_filter_map, 603 | ignored_map=result.ignored_map, 604 | ) 605 | ).execute_copy_request() 606 | 607 | assert result.is_copy_successful 608 | 609 | new_tasks = Task.objects.filter(project=project2) 610 | assert len(new_tasks) == 1 611 | assert new_tasks[0].name == task2.name 612 | 613 | 614 | @pytest.mark.django_db 615 | def test_ignore_condition_nested(): 616 | company = Company.objects.create(name="Company", address="Address") 617 | project1 = Project.objects.create(name="Project1", company=company) 618 | project2 = Project.objects.create(name="Project2", company=company) 619 | counterpart1 = Counterpart.objects.create( 620 | name="Counterpart", external_id=1, project=project1 621 | ) 622 | counterpart2 = Counterpart.objects.create( 623 | name="Counterpart 2", external_id=2, project=project1 624 | ) 625 | counterpart3 = Counterpart.objects.create( 626 | name="Counterpart", external_id=1, project=project2 627 | ) 628 | employee = Employee.objects.create(name="Employee", company=company) 629 | task1 = Task.objects.create( 630 | name="Task", 631 | description="Description", 632 | assignee=employee, 633 | project=project1, 634 | ) 635 | task1.counterparts.add(counterpart1, counterpart2) 636 | task2 = Task.objects.create( 637 | name="Task 2", 638 | description="Description", 639 | assignee=employee, 640 | project=project1, 641 | ) 642 | task2.counterparts.add(counterpart1) 643 | 644 | config = ModelCopyConfig( 645 | model=Employee, 646 | filter_field_to_input_key={"id": "employee_id"}, 647 | ignore_condition=IgnoreCondition( 648 | filter_conditions=[ 649 | IgnoreFilter( 650 | filter_name="tasks__counterparts__id__in", 651 | set_to_filter_field_name="counterparts", 652 | set_to_filter_origin_model=Task, 653 | ) 654 | ] 655 | ), 656 | field_copy_actions={ 657 | "name": TAKE_FROM_ORIGIN, 658 | "company": TAKE_FROM_ORIGIN, 659 | "tasks": MakeCopy( 660 | ModelCopyConfig( 661 | model=Task, 662 | field_copy_actions={ 663 | "name": TAKE_FROM_ORIGIN, 664 | "description": TAKE_FROM_ORIGIN, 665 | "project_id": FieldCopyConfig( 666 | action=CopyActions.TAKE_FROM_INPUT, 667 | input_key="new_project_id", 668 | ), 669 | "counterparts": FieldCopyConfig( 670 | action=CopyActions.SET_TO_FILTER, 671 | reference_to=Counterpart, 672 | filter_config=FilterConfig( 673 | filters={ 674 | "project_id": FieldFilterConfig( 675 | source=FilterSource.FROM_INPUT, 676 | key="new_project_id", 677 | ), 678 | "external_id": FieldFilterConfig( 679 | source=FilterSource.FROM_ORIGIN 680 | ), 681 | } 682 | ), 683 | ), 684 | }, 685 | ) 686 | ), 687 | }, 688 | ) 689 | 690 | result = Copyist( 691 | CopyRequest( 692 | config=CopyistConfig([config]), 693 | input_data={ 694 | "employee_id": employee.id, 695 | "new_project_id": project2.id, 696 | }, 697 | confirm_write=False, 698 | ) 699 | ).execute_copy_request() 700 | 701 | assert not result.is_copy_successful 702 | assert result.reason == AbortReason.IGNORED 703 | 704 | assert result.ignored_map[Employee.__name__] == [employee.id] 705 | 706 | 707 | @pytest.mark.django_db 708 | def test_ignore_condition_with_func(): 709 | company = Company.objects.create(name="Company", address="Address") 710 | project1 = Project.objects.create(name="Project1", company=company) 711 | project2 = Project.objects.create(name="Project2", company=company) 712 | counterpart1 = Counterpart.objects.create( 713 | name="Counterpart", external_id=1, project=project1 714 | ) 715 | counterpart2 = Counterpart.objects.create( 716 | name="Counterpart 2", external_id=2, project=project1 717 | ) 718 | counterpart3 = Counterpart.objects.create( 719 | name="Counterpart", external_id=1, project=project2 720 | ) 721 | employee = Employee.objects.create(name="Employee", company=company) 722 | task1 = Task.objects.create( 723 | name="Task", 724 | description="Description", 725 | assignee=employee, 726 | project=project1, 727 | ) 728 | task1.counterparts.add(counterpart1, counterpart2) 729 | task2 = Task.objects.create( 730 | name="Task 2", 731 | description="Description", 732 | assignee=employee, 733 | project=project1, 734 | ) 735 | task2.counterparts.add(counterpart1) 736 | 737 | def ignore_tasks( 738 | model_config: "ModelCopyConfig", 739 | set_to_filter_map: "SetToFilterMap", 740 | model_extra_filter: Q | None, 741 | ignored_map: "IgnoredMap", 742 | input_data: dict[str, Any], 743 | ) -> list[Model]: 744 | not_matched_counterparts = { 745 | key 746 | for key, value in set_to_filter_map[Task.__name__]["counterparts"].items() 747 | if value is None 748 | } 749 | query = Task.objects.filter(counterparts__id__in=not_matched_counterparts) 750 | if model_extra_filter: 751 | query = query.filter(model_extra_filter) 752 | return list(query) 753 | 754 | config = ModelCopyConfig( 755 | model=Employee, 756 | filter_field_to_input_key={"id": "employee_id"}, 757 | field_copy_actions={ 758 | "name": TAKE_FROM_ORIGIN, 759 | "company": TAKE_FROM_ORIGIN, 760 | "tasks": MakeCopy( 761 | ModelCopyConfig( 762 | model=Task, 763 | ignore_condition=IgnoreCondition( 764 | ignore_func=ignore_tasks, 765 | ), 766 | field_copy_actions={ 767 | "name": TAKE_FROM_ORIGIN, 768 | "description": TAKE_FROM_ORIGIN, 769 | "project_id": FieldCopyConfig( 770 | action=CopyActions.TAKE_FROM_INPUT, 771 | input_key="new_project_id", 772 | ), 773 | "counterparts": FieldCopyConfig( 774 | action=CopyActions.SET_TO_FILTER, 775 | reference_to=Counterpart, 776 | filter_config=FilterConfig( 777 | filters={ 778 | "project_id": FieldFilterConfig( 779 | source=FilterSource.FROM_INPUT, 780 | key="new_project_id", 781 | ), 782 | "external_id": FieldFilterConfig( 783 | source=FilterSource.FROM_ORIGIN 784 | ), 785 | } 786 | ), 787 | ), 788 | }, 789 | ) 790 | ), 791 | }, 792 | ) 793 | 794 | result = Copyist( 795 | CopyRequest( 796 | config=CopyistConfig([config]), 797 | input_data={ 798 | "employee_id": employee.id, 799 | "new_project_id": project2.id, 800 | }, 801 | confirm_write=False, 802 | ) 803 | ).execute_copy_request() 804 | 805 | assert not result.is_copy_successful 806 | assert result.reason == AbortReason.IGNORED 807 | 808 | assert result.ignored_map[Task.__name__] == [task1.id] 809 | 810 | result = Copyist( 811 | CopyRequest( 812 | config=CopyistConfig([config]), 813 | input_data={ 814 | "employee_id": employee.id, 815 | "new_project_id": project2.id, 816 | }, 817 | confirm_write=True, 818 | set_to_filter_map=result.set_to_filter_map, 819 | ignored_map=result.ignored_map, 820 | ) 821 | ).execute_copy_request() 822 | 823 | assert result.is_copy_successful 824 | 825 | new_tasks = Task.objects.filter(project=project2) 826 | assert len(new_tasks) == 1 827 | assert new_tasks[0].name == task2.name 828 | 829 | 830 | @pytest.mark.django_db 831 | def test_static_filters(): 832 | company = Company.objects.create(name="Company", address="Address") 833 | employee = Employee.objects.create(name="Employee", company=company) 834 | employee2 = Employee.objects.create(name="Employee 2 [FIRED]", company=company) 835 | 836 | config = ModelCopyConfig( 837 | model=Company, 838 | filter_field_to_input_key={"id": "company_id"}, 839 | field_copy_actions={ 840 | "name": TAKE_FROM_ORIGIN, 841 | "address": TAKE_FROM_ORIGIN, 842 | "employees": MakeCopy( 843 | ModelCopyConfig( 844 | model=Employee, 845 | static_filters=~Q(name__icontains="[FIRED]"), 846 | field_copy_actions={ 847 | "name": TAKE_FROM_ORIGIN, 848 | }, 849 | ) 850 | ), 851 | }, 852 | ) 853 | result = Copyist( 854 | CopyRequest( 855 | config=CopyistConfig([config]), 856 | input_data={"company_id": company.id}, 857 | confirm_write=False, 858 | ) 859 | ).execute_copy_request() 860 | 861 | assert result.is_copy_successful 862 | assert len(result.output_map["Company"]) == 1 863 | assert len(result.output_map["Employee"]) == 1 864 | 865 | new_company_id = result.output_map["Company"][str(company.id)] 866 | new_employees = Employee.objects.filter(company_id=new_company_id) 867 | assert len(new_employees) == 1 868 | new_employee = new_employees[0] 869 | assert new_employee.name == employee.name 870 | 871 | 872 | @pytest.mark.django_db 873 | def test_data_preparation_steps(): 874 | company = Company.objects.create(name="Company", address="Address") 875 | project1 = Project.objects.create(name="Project1", company=company) 876 | project2 = Project.objects.create(name="Project2", company=company) 877 | counterpart11 = Counterpart.objects.create( 878 | name="11", external_id=1, project=project1 879 | ) 880 | counterpart12 = Counterpart.objects.create( 881 | name="12", external_id=2, project=project1 882 | ) 883 | counterpart21 = Counterpart.objects.create( 884 | name="21", external_id=1, project=project2 885 | ) 886 | counterpart23 = Counterpart.objects.create( 887 | name="23", external_id=3, project=project2 888 | ) 889 | 890 | config = ModelCopyConfig( 891 | model=Counterpart, 892 | filter_field_to_input_key={"project_id": "source_project_id"}, 893 | data_preparation_steps=[ 894 | DataPreparationStep( 895 | action=DataModificationActions.DELETE_BY_FILTER, 896 | filter_field_to_input_key={"project_id": "new_project_id"}, 897 | ) 898 | ], 899 | field_copy_actions={ 900 | "name": TAKE_FROM_ORIGIN, 901 | "external_id": TAKE_FROM_ORIGIN, 902 | "project_id": FieldCopyConfig( 903 | action=CopyActions.TAKE_FROM_INPUT, 904 | input_key="new_project_id", 905 | ), 906 | }, 907 | ) 908 | result = Copyist( 909 | CopyRequest( 910 | config=CopyistConfig([config]), 911 | input_data={ 912 | "source_project_id": project1.id, 913 | "new_project_id": project2.id, 914 | }, 915 | confirm_write=False, 916 | ) 917 | ).execute_copy_request() 918 | 919 | assert result.is_copy_successful 920 | 921 | new_counterparts = list( 922 | Counterpart.objects.filter(project_id=project2.id).values_list( 923 | "name", flat=True 924 | ) 925 | ) 926 | assert set(new_counterparts) == {"11", "12"} 927 | 928 | 929 | @pytest.mark.django_db 930 | def test_data_preparation_steps_func(): 931 | company = Company.objects.create(name="Company", address="Address") 932 | project1 = Project.objects.create(name="Project1", company=company) 933 | project2 = Project.objects.create(name="Project2", company=company) 934 | counterpart11 = Counterpart.objects.create( 935 | name="11", external_id=1, project=project1 936 | ) 937 | counterpart12 = Counterpart.objects.create( 938 | name="12", external_id=2, project=project1 939 | ) 940 | counterpart21 = Counterpart.objects.create( 941 | name="21", external_id=1, project=project2 942 | ) 943 | counterpart23 = Counterpart.objects.create( 944 | name="23", external_id=3, project=project2 945 | ) 946 | 947 | def prepare_destination_project( 948 | model_config: "ModelCopyConfig", 949 | input_data: dict[str, Any], 950 | set_to_filter_map: "SetToFilterMap", 951 | output_map: "OutputMap", 952 | ) -> None: 953 | original_external_ids = Counterpart.objects.filter( 954 | project_id=input_data["source_project_id"] 955 | ).values_list("external_id", flat=True) 956 | 957 | Counterpart.objects.filter( 958 | project_id=input_data["new_project_id"], 959 | external_id__in=original_external_ids, 960 | ).delete() 961 | 962 | config = ModelCopyConfig( 963 | model=Counterpart, 964 | filter_field_to_input_key={"project_id": "source_project_id"}, 965 | data_preparation_steps=[ 966 | DataPreparationStep( 967 | action=DataModificationActions.EXECUTE_FUNC, 968 | func=prepare_destination_project, 969 | ) 970 | ], 971 | field_copy_actions={ 972 | "name": TAKE_FROM_ORIGIN, 973 | "external_id": TAKE_FROM_ORIGIN, 974 | "project_id": FieldCopyConfig( 975 | action=CopyActions.TAKE_FROM_INPUT, 976 | input_key="new_project_id", 977 | ), 978 | }, 979 | ) 980 | result = Copyist( 981 | CopyRequest( 982 | config=CopyistConfig([config]), 983 | input_data={ 984 | "source_project_id": project1.id, 985 | "new_project_id": project2.id, 986 | }, 987 | confirm_write=False, 988 | ) 989 | ).execute_copy_request() 990 | 991 | assert result.is_copy_successful 992 | 993 | new_counterparts = list( 994 | Counterpart.objects.filter(project_id=project2.id).values_list( 995 | "name", flat=True 996 | ) 997 | ) 998 | assert set(new_counterparts) == {"11", "12", "23"} 999 | 1000 | 1001 | @pytest.mark.django_db 1002 | def test_post_copy_func(): 1003 | company = Company.objects.create(name="Company", address="Address") 1004 | project1 = Project.objects.create(name="Project1", company=company) 1005 | project2 = Project.objects.create(name="Project2", company=company) 1006 | counterpart11 = Counterpart.objects.create( 1007 | name="11", external_id=1, project=project1 1008 | ) 1009 | counterpart12 = Counterpart.objects.create( 1010 | name="12", external_id=2, project=project1 1011 | ) 1012 | 1013 | def delete_copied_data_in_source( 1014 | model_config: "ModelCopyConfig", 1015 | input_data: dict[str, Any], 1016 | set_to_filter_map: "SetToFilterMap", 1017 | output_map: "OutputMap", 1018 | copy_intent_list: "List[CopyIntent]", 1019 | ) -> None: 1020 | copied_id_list = [intent.origin.id for intent in copy_intent_list] 1021 | Counterpart.objects.filter(id__in=copied_id_list).delete() 1022 | 1023 | config = ModelCopyConfig( 1024 | model=Counterpart, 1025 | filter_field_to_input_key={"project_id": "source_project_id"}, 1026 | postcopy_steps=[ 1027 | PostcopyStep( 1028 | action=DataModificationActions.EXECUTE_FUNC, 1029 | func=delete_copied_data_in_source, 1030 | ) 1031 | ], 1032 | field_copy_actions={ 1033 | "name": TAKE_FROM_ORIGIN, 1034 | "external_id": TAKE_FROM_ORIGIN, 1035 | "project_id": FieldCopyConfig( 1036 | action=CopyActions.TAKE_FROM_INPUT, 1037 | input_key="new_project_id", 1038 | ), 1039 | }, 1040 | ) 1041 | result = Copyist( 1042 | CopyRequest( 1043 | config=CopyistConfig([config]), 1044 | input_data={ 1045 | "source_project_id": project1.id, 1046 | "new_project_id": project2.id, 1047 | }, 1048 | confirm_write=False, 1049 | ) 1050 | ).execute_copy_request() 1051 | 1052 | assert result.is_copy_successful 1053 | 1054 | new_counterparts = list( 1055 | Counterpart.objects.filter(project_id=project2.id).values_list( 1056 | "name", flat=True 1057 | ) 1058 | ) 1059 | assert set(new_counterparts) == {"11", "12"} 1060 | assert Counterpart.objects.filter(project_id=project1.id).count() == 0 1061 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.0.4. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.0/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "django-insecure-badp#c^2iwsjwc=hco#5e)q+btu7fz0-y2@9x@3y6*$a(e5y15" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "example.transport_network", 41 | "example.demo", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "example.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = "example.wsgi.application" 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": "/tmp/db.sqlite3", 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 92 | }, 93 | { 94 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 95 | }, 96 | { 97 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 98 | }, 99 | { 100 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/5.0/topics/i18n/ 107 | 108 | LANGUAGE_CODE = "en-us" 109 | 110 | TIME_ZONE = "UTC" 111 | 112 | USE_I18N = True 113 | 114 | USE_TZ = True 115 | 116 | 117 | # Static files (CSS, JavaScript, Images) 118 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 119 | 120 | STATIC_URL = "static/" 121 | 122 | # Default primary key field type 123 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 124 | 125 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 126 | -------------------------------------------------------------------------------- /example/transport_network/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/example/transport_network/__init__.py -------------------------------------------------------------------------------- /example/transport_network/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DemoConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "example.transport_network" 7 | -------------------------------------------------------------------------------- /example/transport_network/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/example/transport_network/migrations/__init__.py -------------------------------------------------------------------------------- /example/transport_network/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.validators import MaxValueValidator, MinValueValidator 3 | from django.db import models, transaction 4 | from django.utils import timezone 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | class DataFile(models.Model): 9 | file = models.FileField() 10 | source_file_name = models.CharField(max_length=255) 11 | user = models.ForeignKey( 12 | settings.AUTH_USER_MODEL, 13 | related_name="data_files", 14 | on_delete=models.CASCADE, 15 | ) 16 | created = models.DateTimeField(auto_now_add=True) 17 | 18 | 19 | class Project(models.Model): 20 | name = models.TextField(_("Название проекта")) 21 | created = models.DateTimeField(_("Дата начала проекта"), auto_now_add=True) 22 | last_used_date = models.DateTimeField( 23 | _("Время последнего использования"), default=timezone.now 24 | ) 25 | last_data_id = models.PositiveIntegerField(default=0) 26 | 27 | class Meta: 28 | ordering = ("id",) 29 | 30 | 31 | class ProjectFile(models.Model): 32 | project = models.ForeignKey( 33 | Project, related_name="source_files", on_delete=models.CASCADE 34 | ) 35 | file = models.ForeignKey( 36 | DataFile, 37 | related_name="project_files", 38 | on_delete=models.CASCADE, 39 | ) 40 | error_messages = models.JSONField( 41 | _("Load error messages"), blank=True, default=list 42 | ) 43 | 44 | 45 | class Municipality(models.Model): 46 | project = models.ForeignKey( 47 | Project, related_name="municipalities", on_delete=models.CASCADE 48 | ) 49 | name = models.CharField(max_length=200) 50 | 51 | 52 | class RegionType(models.Model): 53 | project = models.ForeignKey( 54 | Project, related_name="region_types", on_delete=models.CASCADE 55 | ) 56 | name = models.CharField(max_length=100) 57 | 58 | 59 | class Region(models.Model): 60 | project = models.ForeignKey( 61 | Project, related_name="regions", on_delete=models.CASCADE 62 | ) 63 | name = models.CharField(max_length=200) 64 | source_dist_id = models.IntegerField() 65 | region_type = models.ForeignKey( 66 | RegionType, related_name="regions", on_delete=models.CASCADE 67 | ) 68 | municipality = models.ForeignKey( 69 | Municipality, 70 | blank=True, 71 | null=True, 72 | related_name="regions", 73 | on_delete=models.CASCADE, 74 | ) 75 | 76 | 77 | class Scenario(models.Model): 78 | project = models.ForeignKey( 79 | Project, related_name="scenarios", on_delete=models.CASCADE 80 | ) 81 | name = models.CharField(max_length=100) 82 | scenario_id = models.IntegerField() 83 | is_base = models.BooleanField(default=False) 84 | year = models.PositiveSmallIntegerField() 85 | 86 | 87 | class Interval(models.Model): 88 | project = models.ForeignKey( 89 | Project, related_name="intervals", on_delete=models.CASCADE 90 | ) 91 | interval_id = models.IntegerField() 92 | interval_name = models.CharField(max_length=100) 93 | day_type = models.CharField(max_length=100) 94 | interval_start = models.TimeField() 95 | interval_end = models.TimeField() 96 | rush_hour = models.BooleanField(default=False) 97 | rush_hour_fraction = models.FloatField( 98 | validators=[MinValueValidator(0.0), MaxValueValidator(1.0)] 99 | ) 100 | 101 | 102 | class Category(models.Model): 103 | project = models.ForeignKey( 104 | Project, related_name="categories", on_delete=models.CASCADE 105 | ) 106 | category_id = models.IntegerField() 107 | name = models.CharField(max_length=50) 108 | is_public = models.BooleanField() 109 | 110 | 111 | class BehaviorType(models.Model): 112 | project = models.ForeignKey( 113 | Project, related_name="behavior_types", on_delete=models.CASCADE 114 | ) 115 | name = models.CharField(max_length=100) 116 | behavior_id = models.PositiveSmallIntegerField() 117 | apply_remote_percent = models.BooleanField() 118 | 119 | 120 | class BehaviorCategoryValue(models.Model): 121 | behavior_type = models.ForeignKey( 122 | BehaviorType, on_delete=models.CASCADE, related_name="category_values" 123 | ) 124 | category = models.ForeignKey( 125 | Category, on_delete=models.CASCADE, related_name="behavior_values" 126 | ) 127 | value = models.FloatField() 128 | 129 | 130 | class VehicleType(models.Model): 131 | project = models.ForeignKey( 132 | Project, related_name="vehicle_types", on_delete=models.CASCADE 133 | ) 134 | name = models.CharField(_("type ts"), max_length=32) 135 | max_speed = models.IntegerField() 136 | is_public = models.BooleanField() 137 | is_editable = models.BooleanField() 138 | transport_type_id = models.IntegerField() 139 | 140 | 141 | class VehicleClass(models.Model): 142 | project = models.ForeignKey( 143 | Project, related_name="vehicle_classes", on_delete=models.CASCADE 144 | ) 145 | name = models.CharField(_("type ts"), max_length=32) 146 | vehicle_type = models.ForeignKey( 147 | VehicleType, related_name="classes", on_delete=models.CASCADE 148 | ) 149 | sits = models.IntegerField() 150 | area = models.IntegerField() 151 | capacity = models.IntegerField() 152 | 153 | 154 | class Node(models.Model): 155 | point = models.TextField() 156 | scenario = models.ForeignKey( 157 | Scenario, related_name="nodes", on_delete=models.CASCADE 158 | ) 159 | 160 | 161 | class Edge(models.Model): 162 | source_edge_id = models.PositiveIntegerField() 163 | first_node = models.ForeignKey( 164 | Node, related_name="edges_starts", on_delete=models.CASCADE 165 | ) 166 | last_node = models.ForeignKey( 167 | Node, related_name="edges_ends", on_delete=models.CASCADE 168 | ) 169 | length = models.FloatField() 170 | scenario = models.ForeignKey( 171 | Scenario, null=True, blank=True, related_name="edges", on_delete=models.CASCADE 172 | ) 173 | vehicle_types = models.ManyToManyField(VehicleType, related_name="edges") 174 | banned_edges = models.ManyToManyField("self") 175 | pedestrian_speed = models.FloatField() 176 | cost = models.FloatField() 177 | zone = models.IntegerField() 178 | lane_num = models.IntegerField() 179 | parking_cost = models.FloatField(default=0) 180 | is_removed = models.BooleanField(default=False) 181 | 182 | 183 | class EdgeVehicleSpeed(models.Model): 184 | edge = models.ForeignKey( 185 | Edge, related_name="vehicle_speeds", on_delete=models.CASCADE 186 | ) 187 | interval = models.ForeignKey(Interval, on_delete=models.CASCADE) 188 | 189 | vehicle_type = models.ForeignKey(VehicleType, on_delete=models.CASCADE) 190 | speed_raw = models.FloatField() 191 | speed_dedicated_lane_raw = models.FloatField() 192 | dedicated_lane = models.BooleanField() 193 | 194 | 195 | class Stop(models.Model): 196 | project = models.ForeignKey(Project, related_name="stops", on_delete=models.CASCADE) 197 | stop_id = models.PositiveIntegerField(_("stop id")) 198 | stop_name = models.CharField(_("stop name"), max_length=200) 199 | node = models.ForeignKey(Node, related_name="stops", on_delete=models.CASCADE) 200 | route_directions = models.ManyToManyField( 201 | "RouteDirection", through="RouteDirectionNode" 202 | ) 203 | 204 | def save(self, *args, **kwargs): 205 | if not self.stop_id: 206 | with transaction.atomic(): 207 | max_stop_id = Stop.objects.filter(project_id=self.project_id).aggregate( 208 | models.Max("stop_id") 209 | )["stop_id__max"] 210 | max_stop_id = max_stop_id or 0 211 | self.stop_id = max_stop_id + 1 212 | super().save(*args, **kwargs) 213 | else: 214 | super().save(*args, **kwargs) 215 | 216 | 217 | class CommunicationType(models.Model): 218 | name = models.CharField(max_length=100, unique=True) 219 | 220 | 221 | class Season(models.Model): 222 | name = models.CharField(max_length=100, unique=True) 223 | 224 | 225 | class RegularTransportationType(models.Model): 226 | name = models.CharField(max_length=100, unique=True) 227 | 228 | 229 | class Route(models.Model): 230 | source_route_id = models.PositiveIntegerField(_("source_route_id")) 231 | route_number = models.CharField(_("route number"), max_length=64) 232 | vehicle_type = models.ForeignKey( 233 | "VehicleType", 234 | on_delete=models.CASCADE, 235 | related_name="routes", 236 | verbose_name=_("vehicle type"), 237 | ) 238 | route_long_name = models.CharField(_("route long name"), max_length=200) 239 | is_circle = models.BooleanField(_("is circle"), default=False) 240 | carrier = models.CharField(max_length=100) 241 | scenario = models.ForeignKey(Scenario, on_delete=models.CASCADE) 242 | attributes = models.ManyToManyField("RouteAttribute", related_name="routes") 243 | communication_type = models.ForeignKey( 244 | CommunicationType, on_delete=models.CASCADE, null=True 245 | ) 246 | season = models.ForeignKey(Season, on_delete=models.CASCADE, null=True) 247 | regular_transportation_type = models.ForeignKey( 248 | RegularTransportationType, on_delete=models.CASCADE, null=True 249 | ) 250 | 251 | def save(self, *args, **kwargs): 252 | if not self.source_route_id: 253 | with transaction.atomic(): 254 | max_source_route_id = Route.objects.filter( 255 | scenario__project_id=self.scenario.project_id 256 | ).aggregate(models.Max("source_route_id"))["source_route_id__max"] 257 | max_source_route_id = max_source_route_id or 0 258 | self.source_route_id = max_source_route_id + 1 259 | super().save(*args, **kwargs) 260 | else: 261 | super().save(*args, **kwargs) 262 | 263 | 264 | class RouteVariant(models.Model): 265 | route = models.ForeignKey(Route, related_name="variants", on_delete=models.CASCADE) 266 | variant_number = models.CharField(_("route number"), max_length=64) 267 | variant_name = models.CharField(_("variant name"), max_length=200) 268 | tariff_id = models.IntegerField(default=1) 269 | tariff = models.IntegerField(default=30) 270 | 271 | 272 | class RouteVehicleCount(models.Model): 273 | route = models.ForeignKey( 274 | Route, related_name="vehicle_count", on_delete=models.CASCADE 275 | ) 276 | vehicle_class = models.ForeignKey(VehicleClass, on_delete=models.CASCADE) 277 | count = models.PositiveSmallIntegerField() 278 | 279 | 280 | class RouteDirection(models.Model): 281 | route_variant = models.ForeignKey( 282 | RouteVariant, related_name="directions", on_delete=models.CASCADE 283 | ) 284 | direction = models.BooleanField(default=False) 285 | length = models.FloatField(default=0) 286 | direction_name = models.CharField(_("direction name"), max_length=200) 287 | number_of_trips = models.PositiveSmallIntegerField(default=0) 288 | 289 | 290 | class RouteDirectionEdge(models.Model): 291 | direction_node_from = models.OneToOneField( 292 | "RouteDirectionNode", related_name="path_out", on_delete=models.CASCADE 293 | ) 294 | direction_node_to = models.OneToOneField( 295 | "RouteDirectionNode", related_name="path_in", on_delete=models.CASCADE 296 | ) 297 | edges = models.ManyToManyField( 298 | Edge, through="RouteDirectionEdgeOrder", related_name="route_direction_edges" 299 | ) 300 | 301 | 302 | class RouteDirectionEdgeOrder(models.Model): 303 | edge = models.ForeignKey(Edge, on_delete=models.CASCADE) 304 | route_direction_edge = models.ForeignKey( 305 | RouteDirectionEdge, 306 | related_name="route_direction_edge_order", 307 | on_delete=models.CASCADE, 308 | ) 309 | order = models.PositiveSmallIntegerField() 310 | 311 | 312 | class RouteDirectionNode(models.Model): 313 | route_direction = models.ForeignKey( 314 | RouteDirection, related_name="path_nodes", on_delete=models.CASCADE 315 | ) 316 | node = models.ForeignKey(Node, related_name="path_nodes", on_delete=models.CASCADE) 317 | order = models.PositiveSmallIntegerField(default=0) 318 | stop = models.ForeignKey( 319 | Stop, 320 | related_name="route_direction_nodes", 321 | blank=True, 322 | null=True, 323 | on_delete=models.CASCADE, 324 | ) 325 | 326 | class Meta: 327 | ordering = ("order",) 328 | 329 | 330 | class RouteAttribute(models.Model): 331 | vehicle_type = models.ForeignKey( 332 | VehicleType, related_name="route_attributes", on_delete=models.CASCADE 333 | ) 334 | attribute_id = models.IntegerField() 335 | name = models.CharField(max_length=20) 336 | value = models.CharField(max_length=20) 337 | 338 | 339 | class ProjectShape(models.Model): 340 | project = models.ForeignKey( 341 | Project, related_name="models", on_delete=models.CASCADE 342 | ) 343 | created = models.DateTimeField(auto_now_add=True) 344 | content = models.TextField() 345 | 346 | 347 | class Forecast(models.Model): 348 | name = models.CharField(max_length=100) 349 | shape = models.ForeignKey( 350 | ProjectShape, related_name="forecasts", on_delete=models.CASCADE 351 | ) 352 | created = models.DateTimeField(auto_now_add=True) 353 | remote_jobs_percent = models.FloatField(default=0) 354 | partial_remote_jobs_percent = models.FloatField(default=0) 355 | 356 | 357 | class RegionTraffic(models.Model): 358 | forecast = models.ForeignKey( 359 | Forecast, 360 | related_name="traffic", 361 | null=True, 362 | blank=True, 363 | on_delete=models.CASCADE, 364 | ) 365 | region_from = models.ForeignKey( 366 | Region, related_name="region_from_forecast", on_delete=models.CASCADE 367 | ) 368 | region_to = models.ForeignKey( 369 | Region, related_name="region_to_forecast", on_delete=models.CASCADE 370 | ) 371 | traffic = models.FloatField() 372 | traffic_car = models.FloatField(default=0) 373 | traffic_pass = models.FloatField(default=0) 374 | traffic_pass_uncut = models.FloatField(default=0) 375 | delta_ttc_traffic = models.FloatField(default=0) 376 | delta_factor_traffic = models.FloatField(default=0) 377 | public_transport_switch = models.FloatField(default=0) 378 | base_traffic = models.ForeignKey( 379 | "self", 380 | blank=True, 381 | null=True, 382 | related_name="forecast_traffic", 383 | on_delete=models.CASCADE, 384 | ) 385 | scenario = models.ForeignKey( 386 | Scenario, blank=True, null=True, on_delete=models.CASCADE 387 | ) 388 | interval = models.ForeignKey( 389 | Interval, blank=True, null=True, on_delete=models.CASCADE 390 | ) 391 | ttc = models.FloatField(blank=True, null=True) 392 | source = models.CharField(max_length=100) 393 | 394 | 395 | class Indicator(models.Model): 396 | name = models.CharField(max_length=100) 397 | project = models.ForeignKey( 398 | Project, related_name="indicators", on_delete=models.CASCADE 399 | ) 400 | vehicle_type = models.ForeignKey( 401 | VehicleType, blank=True, null=True, on_delete=models.CASCADE 402 | ) 403 | category = models.ForeignKey( 404 | Category, blank=True, null=True, on_delete=models.CASCADE 405 | ) 406 | value = models.FloatField() 407 | 408 | 409 | class IndicatorString(models.Model): 410 | name = models.CharField(max_length=100) 411 | project = models.ForeignKey( 412 | Project, related_name="indicator_strings", on_delete=models.CASCADE 413 | ) 414 | vehicle_type = models.ForeignKey( 415 | VehicleType, blank=True, null=True, on_delete=models.CASCADE 416 | ) 417 | category = models.ForeignKey( 418 | Category, blank=True, null=True, on_delete=models.CASCADE 419 | ) 420 | value = models.CharField(max_length=100) 421 | -------------------------------------------------------------------------------- /example/transport_network/network_copy_config.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any 2 | 3 | from django.db.models import Model, Q 4 | 5 | from django_copyist.config import ( 6 | TAKE_FROM_ORIGIN, 7 | CopyActions, 8 | DataModificationActions, 9 | DataPreparationStep, 10 | FieldCopyConfig, 11 | FieldFilterConfig, 12 | FilterConfig, 13 | FilterSource, 14 | IgnoreCondition, 15 | IgnoreFilter, 16 | IgnoreFilterSource, 17 | ModelCopyConfig, 18 | ) 19 | from example.transport_network.models import ( 20 | Edge, 21 | Forecast, 22 | Node, 23 | Project, 24 | Route, 25 | RouteAttribute, 26 | RouteDirection, 27 | RouteDirectionEdge, 28 | RouteDirectionEdgeOrder, 29 | RouteDirectionNode, 30 | RouteVariant, 31 | RouteVehicleCount, 32 | Scenario, 33 | Stop, 34 | ) 35 | 36 | if TYPE_CHECKING: 37 | from django_copyist.copyist import FieldSetToFilterMap, OutputMap, SetToFilterMap 38 | 39 | 40 | def delete_forecasts( 41 | model_config: ModelCopyConfig, 42 | input_data: dict[str, Any], 43 | set_to_filter_map: "SetToFilterMap", 44 | output_map: "OutputMap", 45 | ) -> None: 46 | project = Project.objects.get(scenarios=input_data["target_scenario_id"]) 47 | 48 | Forecast.objects.filter(shape__project=project).delete() 49 | 50 | 51 | def find_matching_stops_by_nodes( 52 | model_config: "ModelCopyConfig", 53 | input_data: dict[str, Any], 54 | field_name: str, 55 | field_copy_config: "FieldCopyConfig", 56 | set_to_filter_map: "SetToFilterMap", 57 | instance_list: list[Model], 58 | referenced_instance_list: list[Model], 59 | ) -> "FieldSetToFilterMap": 60 | stop_id_list = [s.id for s in referenced_instance_list] 61 | 62 | stops_with_nodes = list( 63 | Stop.objects.filter(id__in=stop_id_list).select_related("node") 64 | ) 65 | 66 | point_list = [s.node.point for s in stops_with_nodes] 67 | substitute_stops = Stop.objects.filter( 68 | node__point__in=point_list, 69 | node__scenario_id=input_data["target_scenario_id"], 70 | ).select_related("node") 71 | point_substitute_map = {s.node.point: s.pk for s in substitute_stops} 72 | 73 | field_set_to_filter_map = {} 74 | for stop in stops_with_nodes: 75 | point = stop.node.point 76 | field_set_to_filter_map[str(stop.id)] = point_substitute_map.get(point) 77 | return field_set_to_filter_map 78 | 79 | 80 | def find_matching_edges( 81 | model_config: "ModelCopyConfig", 82 | input_data: dict[str, Any], 83 | field_name: str, 84 | field_copy_config: "FieldCopyConfig", 85 | set_to_filter_map: "SetToFilterMap", 86 | instance_list: list[Model], 87 | referenced_instance_list: list[Model], 88 | ) -> "FieldSetToFilterMap": 89 | original_edge_id_list = [i.pk for i in referenced_instance_list] 90 | referenced_instance_list_with_prefetched = Edge.objects.filter( 91 | id__in=original_edge_id_list 92 | ).select_related( 93 | "first_node", 94 | "last_node", 95 | ) 96 | position_filter = Q() 97 | edge_to_point_map: dict[int, tuple[str, str]] = {} 98 | 99 | for edge in referenced_instance_list_with_prefetched: 100 | position_filter |= Q(first_node__point=edge.first_node.point) & ( 101 | Q(last_node__point=edge.last_node.point) 102 | ) 103 | edge_to_point_map[edge.id] = (edge.first_node.point, edge.last_node.point) 104 | 105 | substitute_list = ( 106 | Edge.objects.filter(position_filter) 107 | .filter( 108 | scenario_id=input_data["target_scenario_id"], 109 | ) 110 | .select_related( 111 | "first_node", 112 | "last_node", 113 | ) 114 | .prefetch_related("vehicle_types") 115 | ) 116 | point_to_substitute_list: dict[tuple[str, str], int] = {} 117 | substitute_map: dict[int, Edge] = {} 118 | for edge in substitute_list: 119 | point_to_substitute_list[(edge.first_node.point, edge.last_node.point)] = ( 120 | edge.id 121 | ) 122 | substitute_map[edge.id] = edge 123 | 124 | field_set_to_filter_map = {} 125 | for edge in referenced_instance_list: 126 | edge_points = edge_to_point_map[edge.id] 127 | substitute_id = point_to_substitute_list.get(edge_points) 128 | if not substitute_id: 129 | field_set_to_filter_map[str(edge.pk)] = None 130 | continue 131 | 132 | substitute = substitute_map[substitute_id] 133 | field_set_to_filter_map[str(edge.pk)] = str(substitute.pk) 134 | return field_set_to_filter_map 135 | 136 | 137 | ROUTE_VARIANT_COPY_CONFIG = ModelCopyConfig( 138 | model=RouteVariant, 139 | field_copy_actions={ 140 | "tariff": TAKE_FROM_ORIGIN, 141 | "tariff_id": TAKE_FROM_ORIGIN, 142 | "variant_name": TAKE_FROM_ORIGIN, 143 | "variant_number": TAKE_FROM_ORIGIN, 144 | "directions": FieldCopyConfig( 145 | action=CopyActions.MAKE_COPY, 146 | copy_with_config=ModelCopyConfig( 147 | model=RouteDirection, 148 | ignore_condition=IgnoreCondition( 149 | filter_conditions=[ 150 | IgnoreFilter( 151 | filter_name="path_nodes__node__in", 152 | filter_source=IgnoreFilterSource.UNMATCHED_SET_TO_FILTER_VALUES, 153 | set_to_filter_origin_model=RouteDirectionNode, 154 | set_to_filter_field_name="node", 155 | ), 156 | IgnoreFilter( 157 | filter_name="path_nodes__stop__in", 158 | filter_source=IgnoreFilterSource.UNMATCHED_SET_TO_FILTER_VALUES, 159 | set_to_filter_origin_model=RouteDirectionNode, 160 | set_to_filter_field_name="stop", 161 | ), 162 | IgnoreFilter( 163 | filter_name=( 164 | "path_nodes__path_out__route_direction_edge_order__edge__in" 165 | ), 166 | filter_source=IgnoreFilterSource.UNMATCHED_SET_TO_FILTER_VALUES, 167 | set_to_filter_origin_model=RouteDirectionEdgeOrder, 168 | set_to_filter_field_name="edge", 169 | ), 170 | IgnoreFilter( 171 | filter_name="path_nodes__path_in__route_direction_edge_order__edge__in", 172 | filter_source=IgnoreFilterSource.UNMATCHED_SET_TO_FILTER_VALUES, 173 | set_to_filter_origin_model=RouteDirectionEdgeOrder, 174 | set_to_filter_field_name="edge", 175 | ), 176 | ] 177 | ), 178 | field_copy_actions={ 179 | "direction": TAKE_FROM_ORIGIN, 180 | "length": TAKE_FROM_ORIGIN, 181 | "direction_name": TAKE_FROM_ORIGIN, 182 | "number_of_trips": TAKE_FROM_ORIGIN, 183 | "path_nodes": FieldCopyConfig( 184 | action=CopyActions.MAKE_COPY, 185 | copy_with_config=ModelCopyConfig( 186 | model=RouteDirectionNode, 187 | field_copy_actions={ 188 | "order": TAKE_FROM_ORIGIN, 189 | "stop": FieldCopyConfig( 190 | action=CopyActions.SET_TO_FILTER, 191 | filter_config=FilterConfig( 192 | filter_func=find_matching_stops_by_nodes 193 | ), 194 | reference_to=Stop, 195 | ), 196 | "node": FieldCopyConfig( 197 | action=CopyActions.SET_TO_FILTER, 198 | reference_to=Node, 199 | filter_config=FilterConfig( 200 | filters={ 201 | "scenario_id": FieldFilterConfig( 202 | source=FilterSource.FROM_INPUT, 203 | key="target_scenario_id", 204 | ), 205 | "point": FieldFilterConfig( 206 | source=FilterSource.FROM_ORIGIN 207 | ), 208 | } 209 | ), 210 | ), 211 | }, 212 | ), 213 | ), 214 | }, 215 | ), 216 | ), 217 | }, 218 | ) 219 | 220 | 221 | ROUTE_NETWORK_COPY_CONFIG = ModelCopyConfig( 222 | model=Route, 223 | filter_field_to_input_key={ 224 | "scenario_id": "origin_scenario_id", 225 | }, 226 | data_preparation_steps=[ 227 | DataPreparationStep( 228 | action=DataModificationActions.EXECUTE_FUNC, func=delete_forecasts 229 | ), 230 | DataPreparationStep( 231 | action=DataModificationActions.DELETE_BY_FILTER, 232 | filter_field_to_input_key={ 233 | "scenario_id": "target_scenario_id", 234 | }, 235 | ), 236 | ], 237 | field_copy_actions={ 238 | "source_route_id": TAKE_FROM_ORIGIN, 239 | "route_number": TAKE_FROM_ORIGIN, 240 | "vehicle_type": TAKE_FROM_ORIGIN, 241 | "route_long_name": TAKE_FROM_ORIGIN, 242 | "is_circle": TAKE_FROM_ORIGIN, 243 | "carrier": TAKE_FROM_ORIGIN, 244 | "communication_type": TAKE_FROM_ORIGIN, 245 | "season": TAKE_FROM_ORIGIN, 246 | "regular_transportation_type": TAKE_FROM_ORIGIN, 247 | "scenario": FieldCopyConfig( 248 | action=CopyActions.SET_TO_FILTER, 249 | reference_to=Scenario, 250 | filter_config=FilterConfig( 251 | filters={ 252 | "id": FieldFilterConfig( 253 | source=FilterSource.FROM_INPUT, key="target_scenario_id" 254 | ) 255 | } 256 | ), 257 | ), 258 | "vehicle_count": FieldCopyConfig( 259 | action=CopyActions.MAKE_COPY, 260 | copy_with_config=ModelCopyConfig( 261 | model=RouteVehicleCount, 262 | field_copy_actions={ 263 | "vehicle_class": TAKE_FROM_ORIGIN, 264 | "count": TAKE_FROM_ORIGIN, 265 | }, 266 | ), 267 | ), 268 | "variants": FieldCopyConfig( 269 | action=CopyActions.MAKE_COPY, 270 | copy_with_config=ROUTE_VARIANT_COPY_CONFIG, 271 | ), 272 | }, 273 | compound_copy_actions=[ 274 | ModelCopyConfig( 275 | model=RouteAttribute, 276 | field_copy_actions={ 277 | "vehicle_type": TAKE_FROM_ORIGIN, 278 | "attribute_id": TAKE_FROM_ORIGIN, 279 | "name": TAKE_FROM_ORIGIN, 280 | "value": TAKE_FROM_ORIGIN, 281 | "routes": FieldCopyConfig( 282 | action=CopyActions.UPDATE_TO_COPIED, 283 | reference_to=Route, 284 | ), 285 | }, 286 | ), 287 | ModelCopyConfig( 288 | model=RouteDirectionEdge, 289 | field_copy_actions={ 290 | "direction_node_from": FieldCopyConfig( 291 | action=CopyActions.UPDATE_TO_COPIED, 292 | reference_to=RouteDirectionNode, 293 | ), 294 | "direction_node_to": FieldCopyConfig( 295 | action=CopyActions.UPDATE_TO_COPIED, 296 | reference_to=RouteDirectionNode, 297 | ), 298 | "route_direction_edge_order": FieldCopyConfig( 299 | action=CopyActions.MAKE_COPY, 300 | copy_with_config=ModelCopyConfig( 301 | model=RouteDirectionEdgeOrder, 302 | field_copy_actions={ 303 | "order": TAKE_FROM_ORIGIN, 304 | "edge": FieldCopyConfig( 305 | action=CopyActions.SET_TO_FILTER, 306 | reference_to=Edge, 307 | filter_config=FilterConfig( 308 | filter_func=find_matching_edges, 309 | ), 310 | ), 311 | }, 312 | ), 313 | ), 314 | }, 315 | ), 316 | ], 317 | ) 318 | -------------------------------------------------------------------------------- /example/transport_network/project_copy_config.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, List 2 | 3 | from django.db.models import Q 4 | 5 | from django_copyist.config import ( 6 | TAKE_FROM_ORIGIN, 7 | DataModificationActions, 8 | MakeCopy, 9 | ModelCopyConfig, 10 | PostcopyStep, 11 | UpdateToCopied, 12 | ) 13 | from example.transport_network.models import ( 14 | BehaviorCategoryValue, 15 | BehaviorType, 16 | Category, 17 | Edge, 18 | EdgeVehicleSpeed, 19 | Indicator, 20 | IndicatorString, 21 | Interval, 22 | Municipality, 23 | Node, 24 | Project, 25 | ProjectFile, 26 | Region, 27 | RegionTraffic, 28 | RegionType, 29 | Route, 30 | RouteAttribute, 31 | RouteDirection, 32 | RouteDirectionEdge, 33 | RouteDirectionEdgeOrder, 34 | RouteDirectionNode, 35 | RouteVariant, 36 | RouteVehicleCount, 37 | Scenario, 38 | Stop, 39 | VehicleClass, 40 | VehicleType, 41 | ) 42 | 43 | if TYPE_CHECKING: 44 | from django_copyist.copyist import CopyIntent, OutputMap, SetToFilterMap 45 | 46 | 47 | BEHAVIOR_TYPE_MODEL_COPY_CONFIG = ModelCopyConfig( 48 | model=BehaviorType, 49 | field_copy_actions={ 50 | "name": TAKE_FROM_ORIGIN, 51 | "behavior_id": TAKE_FROM_ORIGIN, 52 | "apply_remote_percent": TAKE_FROM_ORIGIN, 53 | "category_values": MakeCopy( 54 | ModelCopyConfig( 55 | model=BehaviorCategoryValue, 56 | field_copy_actions={ 57 | "category": UpdateToCopied(Category), 58 | "value": TAKE_FROM_ORIGIN, 59 | }, 60 | ), 61 | ), 62 | }, 63 | ) 64 | 65 | VEHICLE_TYPE_MODEL_COPY_CONFIG = ModelCopyConfig( 66 | model=VehicleType, 67 | field_copy_actions={ 68 | "name": TAKE_FROM_ORIGIN, 69 | "transport_type_id": TAKE_FROM_ORIGIN, 70 | "max_speed": TAKE_FROM_ORIGIN, 71 | "is_public": TAKE_FROM_ORIGIN, 72 | "is_editable": TAKE_FROM_ORIGIN, 73 | "classes": MakeCopy( 74 | ModelCopyConfig( 75 | model=VehicleClass, 76 | field_copy_actions={ 77 | "project": UpdateToCopied(Project), 78 | "name": TAKE_FROM_ORIGIN, 79 | "sits": TAKE_FROM_ORIGIN, 80 | "area": TAKE_FROM_ORIGIN, 81 | "capacity": TAKE_FROM_ORIGIN, 82 | }, 83 | ), 84 | ), 85 | "route_attributes": MakeCopy( 86 | ModelCopyConfig( 87 | model=RouteAttribute, 88 | field_copy_actions={ 89 | "attribute_id": TAKE_FROM_ORIGIN, 90 | "name": TAKE_FROM_ORIGIN, 91 | "value": TAKE_FROM_ORIGIN, 92 | }, 93 | ), 94 | ), 95 | }, 96 | ) 97 | 98 | MUNICIPALITIES_MODEL_COPY_CONFIG = ModelCopyConfig( 99 | model=Municipality, 100 | field_copy_actions={ 101 | "name": TAKE_FROM_ORIGIN, 102 | "regions": MakeCopy( 103 | ModelCopyConfig( 104 | model=Region, 105 | field_copy_actions={ 106 | "project": UpdateToCopied(Project), 107 | "name": TAKE_FROM_ORIGIN, 108 | "source_dist_id": TAKE_FROM_ORIGIN, 109 | "region_type": TAKE_FROM_ORIGIN, 110 | }, 111 | ), 112 | ), 113 | }, 114 | ) 115 | 116 | 117 | def set_base_region_traffic( 118 | model_config: "ModelCopyConfig", 119 | input_data: dict[str, Any], 120 | set_to_filter_map: "SetToFilterMap", 121 | output_map: "OutputMap", 122 | copy_intent_list: "List[CopyIntent]", 123 | ) -> None: 124 | base_traffic_to_key = {} 125 | for copy_intent in copy_intent_list: 126 | if copy_intent.copied.scenario.is_base: 127 | traffic = copy_intent.copied 128 | traffic_key = f"{traffic.region_from_id}__{traffic.region_to_id}__{traffic.interval_id}" 129 | base_traffic_to_key[traffic_key] = traffic 130 | forecast_traffic_to_update = [] 131 | for copy_intent in copy_intent_list: 132 | if not copy_intent.copied.scenario.is_base: 133 | traffic = copy_intent.copied 134 | traffic_key = f"{traffic.region_from_id}__{traffic.region_to_id}__{traffic.interval_id}" 135 | traffic.base_traffic = base_traffic_to_key[traffic_key] 136 | forecast_traffic_to_update.append(traffic) 137 | RegionTraffic.objects.bulk_update(forecast_traffic_to_update, ["base_traffic"]) 138 | 139 | 140 | SCENARIO_MODEL_COPY_CONFIG = ModelCopyConfig( 141 | model=Scenario, 142 | field_copy_actions={ 143 | "name": TAKE_FROM_ORIGIN, 144 | "scenario_id": TAKE_FROM_ORIGIN, 145 | "is_base": TAKE_FROM_ORIGIN, 146 | "year": TAKE_FROM_ORIGIN, 147 | "nodes": MakeCopy( 148 | ModelCopyConfig( 149 | model=Node, 150 | field_copy_actions={ 151 | "point": TAKE_FROM_ORIGIN, 152 | "stops": MakeCopy( 153 | ModelCopyConfig( 154 | model=Stop, 155 | field_copy_actions={ 156 | "project": UpdateToCopied(Project), 157 | "stop_id": TAKE_FROM_ORIGIN, 158 | "stop_name": TAKE_FROM_ORIGIN, 159 | }, 160 | ), 161 | ), 162 | }, 163 | ), 164 | ), 165 | }, 166 | compound_copy_actions=[ 167 | ModelCopyConfig( 168 | model=Edge, 169 | field_copy_actions={ 170 | "scenario": UpdateToCopied(Scenario), 171 | "first_node": UpdateToCopied(Node), 172 | "last_node": UpdateToCopied(Node), 173 | "source_edge_id": TAKE_FROM_ORIGIN, 174 | "length": TAKE_FROM_ORIGIN, 175 | "vehicle_types": UpdateToCopied(VehicleType), 176 | "banned_edges": UpdateToCopied(Edge), 177 | "pedestrian_speed": TAKE_FROM_ORIGIN, 178 | "cost": TAKE_FROM_ORIGIN, 179 | "zone": TAKE_FROM_ORIGIN, 180 | "lane_num": TAKE_FROM_ORIGIN, 181 | "parking_cost": TAKE_FROM_ORIGIN, 182 | "vehicle_speeds": MakeCopy( 183 | ModelCopyConfig( 184 | model=EdgeVehicleSpeed, 185 | field_copy_actions={ 186 | "interval": UpdateToCopied(Interval), 187 | "vehicle_type": UpdateToCopied(VehicleType), 188 | "speed_raw": TAKE_FROM_ORIGIN, 189 | "speed_dedicated_lane_raw": TAKE_FROM_ORIGIN, 190 | "dedicated_lane": TAKE_FROM_ORIGIN, 191 | }, 192 | ), 193 | ), 194 | "is_removed": TAKE_FROM_ORIGIN, 195 | }, 196 | ), 197 | ModelCopyConfig( 198 | model=Route, 199 | field_copy_actions={ 200 | "vehicle_type": UpdateToCopied(VehicleType), 201 | "scenario": UpdateToCopied(Scenario), 202 | "attributes": UpdateToCopied(RouteAttribute), 203 | "source_route_id": TAKE_FROM_ORIGIN, 204 | "route_number": TAKE_FROM_ORIGIN, 205 | "route_long_name": TAKE_FROM_ORIGIN, 206 | "is_circle": TAKE_FROM_ORIGIN, 207 | "carrier": TAKE_FROM_ORIGIN, 208 | "communication_type": TAKE_FROM_ORIGIN, 209 | "season": TAKE_FROM_ORIGIN, 210 | "regular_transportation_type": TAKE_FROM_ORIGIN, 211 | "variants": MakeCopy( 212 | ModelCopyConfig( 213 | model=RouteVariant, 214 | field_copy_actions={ 215 | "variant_number": TAKE_FROM_ORIGIN, 216 | "variant_name": TAKE_FROM_ORIGIN, 217 | "tariff_id": TAKE_FROM_ORIGIN, 218 | "tariff": TAKE_FROM_ORIGIN, 219 | "directions": MakeCopy( 220 | ModelCopyConfig( 221 | model=RouteDirection, 222 | field_copy_actions={ 223 | "direction": TAKE_FROM_ORIGIN, 224 | "length": TAKE_FROM_ORIGIN, 225 | "direction_name": TAKE_FROM_ORIGIN, 226 | "number_of_trips": TAKE_FROM_ORIGIN, 227 | }, 228 | ), 229 | ), 230 | }, 231 | ), 232 | ), 233 | "vehicle_count": MakeCopy( 234 | ModelCopyConfig( 235 | model=RouteVehicleCount, 236 | field_copy_actions={ 237 | "vehicle_class": UpdateToCopied(VehicleClass), 238 | "count": TAKE_FROM_ORIGIN, 239 | }, 240 | ), 241 | ), 242 | }, 243 | ), 244 | ModelCopyConfig( 245 | model=RouteDirectionNode, 246 | field_copy_actions={ 247 | "route_direction": UpdateToCopied(RouteDirection), 248 | "node": UpdateToCopied(Node), 249 | "stop": UpdateToCopied(Stop), 250 | "order": TAKE_FROM_ORIGIN, 251 | }, 252 | ), 253 | ModelCopyConfig( 254 | model=RouteDirectionEdge, 255 | field_copy_actions={ 256 | "direction_node_from": UpdateToCopied(RouteDirectionNode), 257 | "direction_node_to": UpdateToCopied(RouteDirectionNode), 258 | }, 259 | ), 260 | ModelCopyConfig( 261 | model=RouteDirectionEdgeOrder, 262 | field_copy_actions={ 263 | "edge": UpdateToCopied(Edge), 264 | "route_direction_edge": UpdateToCopied(RouteDirectionEdge), 265 | "order": TAKE_FROM_ORIGIN, 266 | }, 267 | ), 268 | ModelCopyConfig( 269 | model=RegionTraffic, 270 | static_filters=Q(forecast__isnull=True), 271 | postcopy_steps=[ 272 | PostcopyStep( 273 | action=DataModificationActions.EXECUTE_FUNC, 274 | func=set_base_region_traffic, 275 | ), 276 | ], 277 | field_copy_actions={ 278 | "region_from": UpdateToCopied(Region), 279 | "region_to": UpdateToCopied(Region), 280 | "scenario": UpdateToCopied(Scenario), 281 | "interval": UpdateToCopied(Interval), 282 | "traffic": TAKE_FROM_ORIGIN, 283 | "traffic_car": TAKE_FROM_ORIGIN, 284 | "traffic_pass": TAKE_FROM_ORIGIN, 285 | "traffic_pass_uncut": TAKE_FROM_ORIGIN, 286 | "ttc": TAKE_FROM_ORIGIN, 287 | "source": TAKE_FROM_ORIGIN, 288 | }, 289 | ), 290 | ], 291 | ) 292 | 293 | 294 | PROJECT_COPY_CONFIG = ModelCopyConfig( 295 | model=Project, 296 | filter_field_to_input_key={"id": "project_id"}, 297 | field_copy_actions={ 298 | "name": TAKE_FROM_ORIGIN, 299 | "source_files": MakeCopy( 300 | ModelCopyConfig( 301 | model=ProjectFile, 302 | field_copy_actions={ 303 | "file": TAKE_FROM_ORIGIN, 304 | "error_messages": TAKE_FROM_ORIGIN, 305 | }, 306 | ), 307 | ), 308 | "intervals": MakeCopy( 309 | ModelCopyConfig( 310 | model=Interval, 311 | field_copy_actions={ 312 | "interval_id": TAKE_FROM_ORIGIN, 313 | "interval_name": TAKE_FROM_ORIGIN, 314 | "day_type": TAKE_FROM_ORIGIN, 315 | "interval_start": TAKE_FROM_ORIGIN, 316 | "interval_end": TAKE_FROM_ORIGIN, 317 | "rush_hour": TAKE_FROM_ORIGIN, 318 | "rush_hour_fraction": TAKE_FROM_ORIGIN, 319 | }, 320 | ), 321 | ), 322 | "region_types": MakeCopy( 323 | ModelCopyConfig( 324 | model=RegionType, 325 | field_copy_actions={ 326 | "name": TAKE_FROM_ORIGIN, 327 | }, 328 | ), 329 | ), 330 | "municipalities": MakeCopy(MUNICIPALITIES_MODEL_COPY_CONFIG), 331 | "categories": MakeCopy( 332 | ModelCopyConfig( 333 | model=Category, 334 | field_copy_actions={ 335 | "name": TAKE_FROM_ORIGIN, 336 | "category_id": TAKE_FROM_ORIGIN, 337 | "is_public": TAKE_FROM_ORIGIN, 338 | }, 339 | ), 340 | ), 341 | "behavior_types": MakeCopy(BEHAVIOR_TYPE_MODEL_COPY_CONFIG), 342 | "vehicle_types": MakeCopy(VEHICLE_TYPE_MODEL_COPY_CONFIG), 343 | "scenarios": MakeCopy(SCENARIO_MODEL_COPY_CONFIG), 344 | "indicators": MakeCopy( 345 | ModelCopyConfig( 346 | model=Indicator, 347 | field_copy_actions={ 348 | "name": TAKE_FROM_ORIGIN, 349 | "vehicle_type": UpdateToCopied(VehicleType), 350 | "category": UpdateToCopied(Category), 351 | "value": TAKE_FROM_ORIGIN, 352 | }, 353 | ) 354 | ), 355 | "indicator_strings": MakeCopy( 356 | ModelCopyConfig( 357 | model=IndicatorString, 358 | field_copy_actions={ 359 | "name": TAKE_FROM_ORIGIN, 360 | "vehicle_type": UpdateToCopied(VehicleType), 361 | "category": UpdateToCopied(Category), 362 | "value": TAKE_FROM_ORIGIN, 363 | }, 364 | ) 365 | ), 366 | }, 367 | ) 368 | -------------------------------------------------------------------------------- /example/transport_network/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/example/transport_network/tests/__init__.py -------------------------------------------------------------------------------- /example/transport_network/tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abondar/django-copyist/066a25378354d456523f0f4236c2d99f36c6c7ec/example/transport_network/tests/conftest.py -------------------------------------------------------------------------------- /example/transport_network/tests/factories.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import time 3 | 4 | import factory.fuzzy 5 | from factory.django import DjangoModelFactory 6 | 7 | from example.transport_network.models import ( 8 | CommunicationType, 9 | Edge, 10 | Interval, 11 | Node, 12 | Project, 13 | Region, 14 | RegionType, 15 | RegularTransportationType, 16 | Route, 17 | Scenario, 18 | Season, 19 | VehicleType, 20 | ) 21 | 22 | 23 | class ProjectFactory(DjangoModelFactory): 24 | class Meta: 25 | model = Project 26 | 27 | name = factory.Faker("pystr") 28 | 29 | 30 | class ScenarioFactory(DjangoModelFactory): 31 | class Meta: 32 | model = Scenario 33 | 34 | name = factory.Faker("pystr") 35 | scenario_id = factory.Faker("pyint") 36 | project = factory.SubFactory(ProjectFactory) 37 | year = factory.Faker("pyint") 38 | 39 | 40 | class IntervalFactory(DjangoModelFactory): 41 | class Meta: 42 | model = Interval 43 | 44 | project = factory.SubFactory(ProjectFactory) 45 | interval_id = factory.lazy_attribute(lambda o: random.randint(1, 999999999)) 46 | interval_name = factory.Faker("pystr") 47 | day_type = factory.Faker("pystr") 48 | interval_start = factory.lazy_attribute(lambda o: time(10, 00)) 49 | interval_end = factory.lazy_attribute(lambda o: time(23, 30)) 50 | rush_hour = factory.Faker("pybool") 51 | rush_hour_fraction = factory.Faker("pyfloat") 52 | 53 | 54 | class RegionTypeFactory(DjangoModelFactory): 55 | class Meta: 56 | model = RegionType 57 | 58 | project = factory.SubFactory(ProjectFactory) 59 | name = factory.Faker("pystr") 60 | 61 | 62 | class RegionFactory(DjangoModelFactory): 63 | class Meta: 64 | model = Region 65 | 66 | project = factory.SubFactory(ProjectFactory) 67 | name = factory.Faker("pystr") 68 | source_dist_id = factory.Faker("pyint") 69 | region_type = factory.SubFactory(RegionTypeFactory) 70 | 71 | 72 | class VehicleTypeFactory(DjangoModelFactory): 73 | class Meta: 74 | model = VehicleType 75 | 76 | project = factory.SubFactory(ProjectFactory) 77 | name = factory.Faker("pystr") 78 | max_speed = factory.Faker("pyint") 79 | is_public = factory.Faker("pybool") 80 | is_editable = True 81 | transport_type_id = factory.Faker("pyint") 82 | 83 | 84 | class SeasonFactory(DjangoModelFactory): 85 | class Meta: 86 | model = Season 87 | 88 | name = factory.Faker("pystr") 89 | 90 | 91 | class CommunicationTypeFactory(DjangoModelFactory): 92 | class Meta: 93 | model = CommunicationType 94 | 95 | name = factory.Faker("pystr") 96 | 97 | 98 | class RegularTransportationTypeFactory(DjangoModelFactory): 99 | class Meta: 100 | model = RegularTransportationType 101 | 102 | name = factory.Faker("pystr") 103 | 104 | 105 | class RouteFactory(DjangoModelFactory): 106 | class Meta: 107 | model = Route 108 | 109 | vehicle_type = factory.SubFactory(VehicleTypeFactory) 110 | scenario = factory.SubFactory(ScenarioFactory) 111 | season = factory.SubFactory(SeasonFactory) 112 | communication_type = factory.SubFactory(CommunicationTypeFactory) 113 | regular_transportation_type = factory.SubFactory(RegularTransportationTypeFactory) 114 | source_route_id = factory.Faker("pyint") 115 | route_number = factory.Faker("pystr") 116 | route_long_name = factory.Faker("pystr") 117 | is_circle = factory.Faker("pybool") 118 | carrier = factory.Faker("pystr") 119 | 120 | 121 | class NodeFactory(DjangoModelFactory): 122 | class Meta: 123 | model = Node 124 | 125 | scenario = factory.SubFactory(ScenarioFactory) 126 | point = factory.Faker("str") 127 | 128 | 129 | class EdgeFactory(DjangoModelFactory): 130 | class Meta: 131 | model = Edge 132 | 133 | first_node = factory.SubFactory(NodeFactory) 134 | last_node = factory.SubFactory(NodeFactory) 135 | scenario = factory.SubFactory(ScenarioFactory) 136 | source_edge_id = factory.Faker("pyint") 137 | length = factory.Faker("pyfloat", positive=True) 138 | pedestrian_speed = factory.Faker("pyfloat", positive=True) 139 | cost = factory.Faker("pyfloat", positive=True) 140 | zone = factory.Faker("pyint") 141 | lane_num = factory.Faker("pyint") 142 | 143 | @factory.post_generation 144 | def vehicle_types(self, create, extracted, **kwargs): 145 | if not create: # Simple build, do nothing. 146 | return 147 | if extracted: # A list of vehicle types were passed in, use them 148 | for vt in extracted: 149 | self.vehicle_types.add(vt) 150 | 151 | @factory.post_generation 152 | def banned_edges(self, create, extracted, **kwargs): 153 | if not create: 154 | return 155 | if extracted: 156 | for edge in extracted: 157 | self.banned_edges.add(edge) 158 | -------------------------------------------------------------------------------- /example/transport_network/tests/test_copyist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_copyist.config import ( 4 | TAKE_FROM_ORIGIN, 5 | CopyActions, 6 | FieldCopyConfig, 7 | ModelCopyConfig, 8 | ) 9 | from django_copyist.copy_request import AbortReason, CopyRequest 10 | from django_copyist.copyist import Copyist, CopyistConfig 11 | from example.transport_network.models import ( 12 | BehaviorCategoryValue, 13 | BehaviorType, 14 | Category, 15 | Edge, 16 | Forecast, 17 | Municipality, 18 | Node, 19 | Project, 20 | ProjectShape, 21 | RegionTraffic, 22 | Route, 23 | RouteAttribute, 24 | RouteDirection, 25 | RouteDirectionEdge, 26 | RouteDirectionEdgeOrder, 27 | RouteDirectionNode, 28 | RouteVariant, 29 | RouteVehicleCount, 30 | Scenario, 31 | Stop, 32 | VehicleClass, 33 | ) 34 | from example.transport_network.network_copy_config import ROUTE_NETWORK_COPY_CONFIG 35 | from example.transport_network.project_copy_config import PROJECT_COPY_CONFIG 36 | from example.transport_network.tests.factories import ( 37 | EdgeFactory, 38 | IntervalFactory, 39 | RegionFactory, 40 | RouteFactory, 41 | VehicleTypeFactory, 42 | ) 43 | 44 | 45 | @pytest.mark.django_db 46 | def test_make_single_copy(): 47 | project = Project.objects.create( 48 | name="Test project", 49 | ) 50 | scenario = Scenario.objects.create( 51 | project=project, 52 | name="Test scenario", 53 | scenario_id=1, 54 | year=2021, 55 | ) 56 | node = Node.objects.create(scenario=scenario, point="1.1") 57 | 58 | config = CopyistConfig( 59 | model_configs=[ 60 | ModelCopyConfig( 61 | model=Node, 62 | filter_field_to_input_key={"id": "node_id"}, 63 | field_copy_actions={ 64 | "scenario": TAKE_FROM_ORIGIN, 65 | "point": TAKE_FROM_ORIGIN, 66 | }, 67 | ) 68 | ] 69 | ) 70 | 71 | copyist = Copyist( 72 | CopyRequest(config=config, input_data={"node_id": node.id}, confirm_write=False) 73 | ) 74 | result = copyist.execute_copy_request() 75 | assert result.is_copy_successful, result.reason 76 | output_map = result.output_map 77 | 78 | model_name = Node.__name__ 79 | assert model_name in output_map, output_map 80 | copy_id = output_map[model_name][str(node.id)] 81 | assert copy_id 82 | copy = Node.objects.get(id=copy_id) 83 | 84 | assert copy.id != node.id 85 | assert copy.scenario_id == node.scenario_id 86 | assert copy.point == node.point 87 | 88 | 89 | @pytest.mark.django_db 90 | def test_make_copy_with_nested_copies(): 91 | project = Project.objects.create(name="Test project") 92 | Scenario.objects.create( 93 | project=project, 94 | name="Test scenario", 95 | scenario_id=1, 96 | year=2021, 97 | ) 98 | Scenario.objects.create( 99 | project=project, 100 | name="Test scenario 2", 101 | scenario_id=2, 102 | year=1970, 103 | ) 104 | 105 | config = CopyistConfig( 106 | model_configs=[ 107 | ModelCopyConfig( 108 | model=Project, 109 | filter_field_to_input_key={"id": "project_id"}, 110 | field_copy_actions={ 111 | "name": TAKE_FROM_ORIGIN, 112 | "scenarios": FieldCopyConfig( 113 | action=CopyActions.MAKE_COPY, 114 | copy_with_config=ModelCopyConfig( 115 | model=Scenario, 116 | field_copy_actions={ 117 | "name": TAKE_FROM_ORIGIN, 118 | "scenario_id": TAKE_FROM_ORIGIN, 119 | "year": TAKE_FROM_ORIGIN, 120 | }, 121 | ), 122 | ), 123 | }, 124 | ) 125 | ] 126 | ) 127 | 128 | copyist = Copyist( 129 | CopyRequest( 130 | config=config, input_data={"project_id": project.id}, confirm_write=False 131 | ) 132 | ) 133 | result = copyist.execute_copy_request() 134 | assert result.is_copy_successful, result.reason 135 | output_map = result.output_map 136 | 137 | model_name = Project.__name__ 138 | scenario_model_name = Scenario.__name__ 139 | assert model_name in output_map, output_map 140 | assert scenario_model_name in output_map, output_map 141 | copy_id = output_map[model_name][str(project.id)] 142 | assert copy_id 143 | copy = Project.objects.get(id=copy_id) 144 | 145 | assert copy.id != project.id 146 | assert copy.name == project.name 147 | 148 | for original_id, copy_scenario_id in output_map[scenario_model_name].items(): 149 | original = Scenario.objects.get(id=original_id) 150 | copy_scenario = Scenario.objects.get(id=copy_scenario_id) 151 | 152 | assert original.name == copy_scenario.name 153 | assert original.project_id == project.id 154 | assert copy_scenario.project_id == copy.id 155 | assert copy_scenario.project_id != original.project_id 156 | 157 | 158 | @pytest.mark.django_db 159 | @pytest.mark.parametrize( 160 | "config", 161 | [ 162 | CopyistConfig( 163 | model_configs=[ 164 | ModelCopyConfig( 165 | model=Project, 166 | filter_field_to_input_key={"id": "project_id"}, 167 | field_copy_actions={ 168 | "name": TAKE_FROM_ORIGIN, 169 | "categories": FieldCopyConfig( 170 | action=CopyActions.MAKE_COPY, 171 | copy_with_config=ModelCopyConfig( 172 | model=Category, 173 | field_copy_actions={ 174 | "name": TAKE_FROM_ORIGIN, 175 | "category_id": TAKE_FROM_ORIGIN, 176 | "is_public": TAKE_FROM_ORIGIN, 177 | }, 178 | ), 179 | ), 180 | "behavior_types": FieldCopyConfig( 181 | action=CopyActions.MAKE_COPY, 182 | copy_with_config=ModelCopyConfig( 183 | model=BehaviorType, 184 | field_copy_actions={ 185 | "name": TAKE_FROM_ORIGIN, 186 | "behavior_id": TAKE_FROM_ORIGIN, 187 | "apply_remote_percent": TAKE_FROM_ORIGIN, 188 | "category_values": FieldCopyConfig( 189 | action=CopyActions.MAKE_COPY, 190 | copy_with_config=ModelCopyConfig( 191 | model=BehaviorCategoryValue, 192 | field_copy_actions={ 193 | "category": FieldCopyConfig( 194 | action=CopyActions.UPDATE_TO_COPIED, 195 | reference_to=Category, 196 | ), 197 | "value": TAKE_FROM_ORIGIN, 198 | }, 199 | ), 200 | ), 201 | }, 202 | ), 203 | ), 204 | }, 205 | ) 206 | ] 207 | ), 208 | CopyistConfig( 209 | model_configs=[ 210 | ModelCopyConfig( 211 | model=Project, 212 | filter_field_to_input_key={"id": "project_id"}, 213 | field_copy_actions={ 214 | "name": TAKE_FROM_ORIGIN, 215 | "categories": FieldCopyConfig( 216 | action=CopyActions.MAKE_COPY, 217 | copy_with_config=ModelCopyConfig( 218 | model=Category, 219 | field_copy_actions={ 220 | "name": TAKE_FROM_ORIGIN, 221 | "category_id": TAKE_FROM_ORIGIN, 222 | "is_public": TAKE_FROM_ORIGIN, 223 | }, 224 | ), 225 | ), 226 | "behavior_types": FieldCopyConfig( 227 | action=CopyActions.MAKE_COPY, 228 | copy_with_config=ModelCopyConfig( 229 | model=BehaviorType, 230 | field_copy_actions={ 231 | "name": TAKE_FROM_ORIGIN, 232 | "behavior_id": TAKE_FROM_ORIGIN, 233 | "apply_remote_percent": TAKE_FROM_ORIGIN, 234 | }, 235 | ), 236 | ), 237 | }, 238 | compound_copy_actions=[ 239 | ModelCopyConfig( 240 | model=BehaviorCategoryValue, 241 | field_copy_actions={ 242 | "category": FieldCopyConfig( 243 | action=CopyActions.UPDATE_TO_COPIED, 244 | reference_to=Category, 245 | ), 246 | "behavior_type": FieldCopyConfig( 247 | action=CopyActions.UPDATE_TO_COPIED, 248 | reference_to=BehaviorType, 249 | ), 250 | "value": TAKE_FROM_ORIGIN, 251 | }, 252 | ) 253 | ], 254 | ) 255 | ] 256 | ), 257 | ], 258 | ) 259 | def test_update_to_copied(config): 260 | project = Project.objects.create(name="Test project") 261 | bt1 = BehaviorType.objects.create( 262 | project=project, name="bt1", behavior_id=1, apply_remote_percent=True 263 | ) 264 | bt2 = BehaviorType.objects.create( 265 | project=project, name="bt2", behavior_id=2, apply_remote_percent=False 266 | ) 267 | c1 = Category.objects.create( 268 | project=project, name="c1", category_id=1, is_public=True 269 | ) 270 | c2 = Category.objects.create( 271 | project=project, name="c2", category_id=2, is_public=False 272 | ) 273 | 274 | BehaviorCategoryValue.objects.create(behavior_type=bt1, category=c1, value=1.5) 275 | BehaviorCategoryValue.objects.create(behavior_type=bt1, category=c2, value=2.5) 276 | BehaviorCategoryValue.objects.create(behavior_type=bt2, category=c2, value=3.5) 277 | 278 | copyist = Copyist( 279 | CopyRequest( 280 | config=config, input_data={"project_id": project.id}, confirm_write=False 281 | ) 282 | ) 283 | result = copyist.execute_copy_request() 284 | assert result.is_copy_successful, result.reason 285 | output_map = result.output_map 286 | 287 | assert Category.__name__ in output_map 288 | assert BehaviorType.__name__ in output_map 289 | assert Project.__name__ in output_map 290 | assert BehaviorCategoryValue.__name__ in output_map 291 | 292 | copy_project = Project.objects.get(id=output_map[Project.__name__][str(project.id)]) 293 | category_copies = list(copy_project.categories.all()) 294 | assert len(category_copies) == 2 295 | for copy_category in category_copies: 296 | assert str(copy_category.pk) in output_map[Category.__name__].values() 297 | assert copy_category.pk not in [c1.pk, c2.pk] 298 | 299 | if copy_category.name == c1.name: 300 | original = c1 301 | else: 302 | original = c2 303 | copy_values = list(copy_category.behavior_values.all()) 304 | original_values = list(original.behavior_values.all()) 305 | 306 | assert {v.value for v in copy_values} == {v.value for v in original_values} 307 | assert {str(v.pk) for v in copy_values}.issubset( 308 | output_map[BehaviorCategoryValue.__name__].values() 309 | ) 310 | assert {str(v.pk) for v in original_values}.issubset( 311 | output_map[BehaviorCategoryValue.__name__].keys() 312 | ) 313 | 314 | 315 | @pytest.mark.django_db 316 | def test_copy_project(): 317 | original_project = Project.objects.create(name="project_original") 318 | original_scenario = Scenario.objects.create( 319 | name="original_scenario", 320 | project=original_project, 321 | scenario_id=1, 322 | year=2021, 323 | is_base=True, 324 | ) 325 | 326 | shape = ProjectShape.objects.create(project=original_project, content="123") 327 | 328 | municipality = Municipality.objects.create(project=original_project, name="m1") 329 | region1 = RegionFactory(project=original_project, municipality=municipality) 330 | region2 = RegionFactory(project=original_project, municipality=municipality) 331 | interval = IntervalFactory(project=original_project) 332 | 333 | forecast = Forecast.objects.create(name="f1", shape=shape) 334 | region_traffic_base = RegionTraffic.objects.create( 335 | region_from=region1, 336 | region_to=region2, 337 | traffic=1, 338 | scenario=original_scenario, 339 | interval=interval, 340 | source="test", 341 | ) 342 | RegionTraffic.objects.create( 343 | region_from=region1, 344 | region_to=region2, 345 | traffic=1, 346 | scenario=original_scenario, 347 | interval=interval, 348 | source="test", 349 | base_traffic=region_traffic_base, 350 | forecast=forecast, 351 | ) 352 | 353 | original_node_1 = Node.objects.create( 354 | scenario=original_scenario, 355 | point="1.1", 356 | ) 357 | original_node_2 = Node.objects.create( 358 | scenario=original_scenario, 359 | point="1.2", 360 | ) 361 | original_node_3 = Node.objects.create( 362 | scenario=original_scenario, 363 | point="2.2", 364 | ) 365 | original_edge_1 = EdgeFactory( 366 | source_edge_id=1, 367 | scenario=original_scenario, 368 | first_node=original_node_1, 369 | last_node=original_node_2, 370 | ) 371 | original_edge_2 = EdgeFactory( 372 | source_edge_id=2, 373 | scenario=original_scenario, 374 | first_node=original_node_2, 375 | last_node=original_node_3, 376 | ) 377 | 378 | original_route = RouteFactory( 379 | scenario=original_scenario, 380 | vehicle_type=VehicleTypeFactory(project=original_project), 381 | ) 382 | attributes = [ 383 | RouteAttribute.objects.create( 384 | vehicle_type=original_route.vehicle_type, 385 | attribute_id=i, 386 | name=str(i), 387 | value=str(i), 388 | ) 389 | for i in range(1, 3) 390 | ] 391 | original_route.attributes.add(*attributes) 392 | vehicle_class = VehicleClass.objects.create( 393 | vehicle_type=original_route.vehicle_type, 394 | project=original_project, 395 | name="vh1", 396 | area=1, 397 | capacity=1, 398 | sits=1, 399 | ) 400 | RouteVehicleCount.objects.create( 401 | route=original_route, count=1, vehicle_class=vehicle_class 402 | ) 403 | 404 | original_route_variant = RouteVariant.objects.create( 405 | route=original_route, 406 | variant_number="1", 407 | variant_name="rv1", 408 | ) 409 | route_direction_1 = RouteDirection.objects.create( 410 | route_variant=original_route_variant, 411 | direction_name="d1", 412 | ) 413 | RouteDirection.objects.create( 414 | route_variant=original_route_variant, direction_name="d2", direction=True 415 | ) 416 | rdn_1 = RouteDirectionNode.objects.create( 417 | route_direction=route_direction_1, 418 | node=original_node_1, 419 | order=1, 420 | ) 421 | rdn_2 = RouteDirectionNode.objects.create( 422 | route_direction=route_direction_1, 423 | node=original_node_2, 424 | order=2, 425 | ) 426 | rdn_3 = RouteDirectionNode.objects.create( 427 | route_direction=route_direction_1, 428 | node=original_node_3, 429 | order=3, 430 | ) 431 | rde_1 = RouteDirectionEdge.objects.create( 432 | direction_node_from=rdn_1, direction_node_to=rdn_2 433 | ) 434 | rde_2 = RouteDirectionEdge.objects.create( 435 | direction_node_from=rdn_2, direction_node_to=rdn_3 436 | ) 437 | RouteDirectionEdgeOrder.objects.create( 438 | route_direction_edge=rde_1, 439 | order=1, 440 | edge=original_edge_1, 441 | ) 442 | RouteDirectionEdgeOrder.objects.create( 443 | route_direction_edge=rde_2, 444 | order=1, 445 | edge=original_edge_1, 446 | ) 447 | RouteDirectionEdgeOrder.objects.create( 448 | route_direction_edge=rde_2, 449 | order=2, 450 | edge=original_edge_2, 451 | ) 452 | 453 | result = Copyist( 454 | copy_request=CopyRequest( 455 | input_data={ 456 | "project_id": original_project.pk, 457 | }, 458 | config=CopyistConfig(model_configs=[PROJECT_COPY_CONFIG]), 459 | confirm_write=False, 460 | ) 461 | ).execute_copy_request() 462 | 463 | assert result.is_copy_successful, ( 464 | result.reason, 465 | result.set_to_filter_map, 466 | result.ignored_map, 467 | ) 468 | copied_project = Project.objects.last() 469 | assert copied_project.pk != original_project.pk 470 | assert copied_project.name == original_project.name 471 | 472 | copied_scenario = Scenario.objects.last() 473 | assert copied_scenario.pk != original_scenario.pk 474 | assert copied_scenario.name == original_scenario.name 475 | assert copied_scenario.scenario_id == original_scenario.scenario_id 476 | 477 | copied_node_1 = Node.objects.filter(point=original_node_1.point).last() 478 | assert copied_node_1.pk != original_node_1.pk 479 | assert copied_node_1.scenario_id != original_node_1.scenario_id 480 | 481 | copied_edge_list = list(Edge.objects.filter(scenario=copied_scenario)) 482 | assert len(copied_edge_list) == 2 483 | assert {e.pk for e in copied_edge_list} != {original_edge_1.pk, original_edge_2.pk} 484 | assert {e.source_edge_id for e in copied_edge_list} == { 485 | original_edge_1.source_edge_id, 486 | original_edge_2.source_edge_id, 487 | } 488 | 489 | traffic = list(RegionTraffic.objects.filter(scenario__project=copied_project)) 490 | assert len(traffic) == 1 491 | assert traffic[0].base_traffic is None 492 | 493 | 494 | MISSING_TARGET_NODE = "MISSING_TARGET_NODE" 495 | MISSING_TARGET_EDGE = "MISSING_TARGET_EDGE" 496 | MISSING_STOP = "MISSING_STOP" 497 | 498 | 499 | @pytest.mark.django_db 500 | @pytest.mark.parametrize( 501 | ["features", "expected_reason"], 502 | [ 503 | [[], None], 504 | [[MISSING_TARGET_EDGE], AbortReason.IGNORED], 505 | [ 506 | [MISSING_TARGET_NODE, MISSING_TARGET_EDGE], 507 | AbortReason.IGNORED, 508 | ], 509 | [[MISSING_STOP], AbortReason.IGNORED], 510 | ], 511 | ) 512 | def test_copy_network(expected_reason, features): # flake8: noqa 513 | project = Project.objects.create(name="project_original") 514 | original_scenario = Scenario.objects.create( 515 | name="original_scenario", project=project, scenario_id=1, year=2021 516 | ) 517 | target_scenario = Scenario.objects.create( 518 | name="target_scenario", project=project, scenario_id=1, year=2022 519 | ) 520 | vt_1 = VehicleTypeFactory(project=project) 521 | vt_2 = VehicleTypeFactory(project=project) 522 | 523 | original_node_1 = Node.objects.create( 524 | scenario=original_scenario, 525 | point="1.1", 526 | ) 527 | origin_stop = Stop.objects.create( 528 | project=project, 529 | stop_id=1, 530 | stop_name="stop1", 531 | node=original_node_1, 532 | ) 533 | original_node_2 = Node.objects.create( 534 | scenario=original_scenario, 535 | point="1.2", 536 | ) 537 | original_node_3 = Node.objects.create( 538 | scenario=original_scenario, 539 | point="2.2", 540 | ) 541 | target_node_1 = Node.objects.create( 542 | scenario=target_scenario, 543 | point="1.1", 544 | ) 545 | target_node_2 = Node.objects.create( 546 | scenario=target_scenario, 547 | point="1.2", 548 | ) 549 | if MISSING_STOP not in features: 550 | target_stop = Stop.objects.create( 551 | project=project, 552 | stop_id=1, 553 | stop_name="stop1", 554 | node=target_node_1, 555 | ) 556 | if MISSING_TARGET_NODE not in features: 557 | target_node_3 = Node.objects.create( 558 | scenario=target_scenario, 559 | point="2.2", 560 | ) 561 | original_edge_1 = EdgeFactory( 562 | source_edge_id=1, 563 | scenario=original_scenario, 564 | first_node=original_node_1, 565 | last_node=original_node_2, 566 | ) 567 | original_edge_2 = EdgeFactory( 568 | source_edge_id=2, 569 | scenario=original_scenario, 570 | first_node=original_node_2, 571 | last_node=original_node_3, 572 | ) 573 | target_edge_1 = EdgeFactory( 574 | source_edge_id=1, 575 | scenario=target_scenario, 576 | first_node=target_node_1, 577 | last_node=target_node_2, 578 | ) 579 | for edge in [original_edge_1, original_edge_2]: 580 | edge.vehicle_types.add(vt_1, vt_2) 581 | target_edge_1.vehicle_types.add(vt_1) 582 | if MISSING_TARGET_EDGE not in features: 583 | target_edge_2 = EdgeFactory( 584 | source_edge_id=2, 585 | scenario=target_scenario, 586 | first_node=target_node_2, 587 | last_node=target_node_3, 588 | ) 589 | target_edge_2.vehicle_types.add(vt_1) 590 | 591 | original_route = RouteFactory(scenario=original_scenario, vehicle_type=vt_1) 592 | routes_to_populate = [original_route] 593 | 594 | attributes = [ 595 | RouteAttribute.objects.create( 596 | vehicle_type=original_route.vehicle_type, 597 | attribute_id=i, 598 | name=str(i), 599 | value=str(i), 600 | ) 601 | for i in range(1, 3) 602 | ] 603 | original_route.attributes.add(*attributes) 604 | vehicle_class = VehicleClass.objects.create( 605 | vehicle_type=original_route.vehicle_type, 606 | project=project, 607 | name="vh1", 608 | area=1, 609 | capacity=1, 610 | sits=1, 611 | ) 612 | vehicle_count = RouteVehicleCount.objects.create( 613 | route=original_route, count=1, vehicle_class=vehicle_class 614 | ) 615 | 616 | for route in routes_to_populate: 617 | route_variant = RouteVariant.objects.create( 618 | route=route, 619 | variant_number="1", 620 | variant_name="rv1", 621 | ) 622 | route_direction_1 = RouteDirection.objects.create( 623 | route_variant=route_variant, 624 | direction_name="d1", 625 | ) 626 | route_direction_2 = RouteDirection.objects.create( 627 | route_variant=route_variant, direction_name="d2", direction=True 628 | ) 629 | rdn_1 = RouteDirectionNode.objects.create( 630 | route_direction=route_direction_1, 631 | node=original_node_1, 632 | order=1, 633 | stop=origin_stop, 634 | ) 635 | rdn_2 = RouteDirectionNode.objects.create( 636 | route_direction=route_direction_1, 637 | node=original_node_2, 638 | order=2, 639 | ) 640 | rdn_3 = RouteDirectionNode.objects.create( 641 | route_direction=route_direction_1, 642 | node=original_node_3, 643 | order=3, 644 | ) 645 | rde_1 = RouteDirectionEdge.objects.create( 646 | direction_node_from=rdn_1, direction_node_to=rdn_2 647 | ) 648 | rde_2 = RouteDirectionEdge.objects.create( 649 | direction_node_from=rdn_2, direction_node_to=rdn_3 650 | ) 651 | RouteDirectionEdgeOrder.objects.create( 652 | route_direction_edge=rde_1, 653 | order=1, 654 | edge=original_edge_1, 655 | ) 656 | RouteDirectionEdgeOrder.objects.create( 657 | route_direction_edge=rde_2, 658 | order=1, 659 | edge=original_edge_1, 660 | ) 661 | RouteDirectionEdgeOrder.objects.create( 662 | route_direction_edge=rde_2, 663 | order=2, 664 | edge=original_edge_2, 665 | ) 666 | 667 | result = Copyist( 668 | copy_request=CopyRequest( 669 | input_data={ 670 | "origin_scenario_id": original_scenario.pk, 671 | "target_scenario_id": target_scenario.pk, 672 | }, 673 | config=CopyistConfig(model_configs=[ROUTE_NETWORK_COPY_CONFIG]), 674 | confirm_write=False, 675 | ), 676 | ).execute_copy_request() 677 | 678 | if expected_reason is None: 679 | assert result.is_copy_successful 680 | assert result.reason is None 681 | route_copy = Route.objects.last() 682 | assert route_copy.pk != original_route.pk 683 | assert route_copy.route_number == original_route.route_number 684 | assert route_copy.scenario_id == target_scenario.pk 685 | assert route_copy.vehicle_type_id == original_route.vehicle_type_id 686 | 687 | copy_attributes = list(route_copy.attributes.all()) 688 | assert len(copy_attributes) == 2 689 | assert {a.pk for a in copy_attributes} != {a.pk for a in attributes} 690 | assert {(a.attribute_id, a.name, a.value) for a in copy_attributes} == { 691 | (a.attribute_id, a.name, a.value) for a in attributes 692 | } 693 | 694 | copy_vehicle_count = RouteVehicleCount.objects.last() 695 | assert copy_vehicle_count.pk != vehicle_count 696 | assert copy_vehicle_count.count == vehicle_count.count 697 | 698 | copy_route_variant = RouteVariant.objects.last() 699 | assert copy_route_variant.pk != route_variant.pk 700 | assert copy_route_variant.variant_name == route_variant.variant_name 701 | assert copy_route_variant.variant_number == route_variant.variant_number 702 | 703 | copy_directions = list(copy_route_variant.directions.all()) 704 | original_directions = (route_direction_1, route_direction_2) 705 | assert len(copy_directions) == 2 706 | assert {d.pk for d in copy_directions} != {d.pk for d in original_directions} 707 | assert {d.direction_name for d in copy_directions} == { 708 | d.direction_name for d in original_directions 709 | } 710 | 711 | copy_direction = next( 712 | d 713 | for d in copy_directions 714 | if d.direction_name == route_direction_1.direction_name 715 | ) 716 | copy_rdn_list = list(copy_direction.path_nodes.all()) 717 | assert len(copy_rdn_list) == 3 718 | assert {r.node_id for r in copy_rdn_list} != { 719 | r.node_id for r in (rdn_1, rdn_2, rdn_3) 720 | } 721 | assert {r.node.point for r in copy_rdn_list} == { 722 | r.node.point for r in (rdn_1, rdn_2, rdn_3) 723 | } 724 | assert all(r.node.scenario_id == target_scenario.pk for r in copy_rdn_list) 725 | copy_rdn_1 = next( 726 | rdn for rdn in copy_rdn_list if rdn.node.point == rdn_1.node.point 727 | ) 728 | assert copy_rdn_1.stop_id == target_stop.pk 729 | 730 | copy_node_id_list = [r.pk for r in copy_rdn_list] 731 | copy_rde_list = list( 732 | RouteDirectionEdge.objects.filter( 733 | direction_node_from_id__in=copy_node_id_list, 734 | direction_node_to_id__in=copy_node_id_list, 735 | ) 736 | ) 737 | assert len(copy_rde_list) == 2 738 | 739 | copy_rdeo_list = list( 740 | RouteDirectionEdgeOrder.objects.filter( 741 | route_direction_edge_id__in=[c.pk for c in copy_rde_list] 742 | ) 743 | ) 744 | assert len(copy_rdeo_list) == 3 745 | assert {c.edge_id for c in copy_rdeo_list} == { 746 | e.id for e in (target_edge_1, target_edge_2) 747 | } 748 | elif expected_reason == AbortReason.IGNORED: 749 | assert not result.is_copy_successful 750 | assert result.reason == expected_reason 751 | 752 | if {MISSING_TARGET_NODE, MISSING_TARGET_EDGE, MISSING_STOP} & set(features): 753 | ignored_directions = [route_direction_1.pk] 754 | assert set(result.ignored_map.get(RouteDirection.__name__)) == set( 755 | ignored_directions 756 | ), result.ignored_map 757 | if MISSING_TARGET_NODE in features: 758 | assert ( 759 | result.set_to_filter_map[RouteDirectionNode.__name__]["node"][ 760 | str(original_node_3.pk) 761 | ] 762 | is None 763 | ) 764 | if MISSING_TARGET_EDGE in features: 765 | assert ( 766 | result.set_to_filter_map[RouteDirectionEdgeOrder.__name__]["edge"][ 767 | str(original_edge_2.pk) 768 | ] 769 | is None 770 | ) 771 | if MISSING_STOP in features: 772 | assert ( 773 | result.set_to_filter_map[RouteDirectionNode.__name__]["stop"][ 774 | str(origin_stop.pk) 775 | ] 776 | is None 777 | ) 778 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for example project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.0/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import path 20 | 21 | urlpatterns = [ 22 | path("admin/", admin.site.urls), 23 | ] 24 | -------------------------------------------------------------------------------- /example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-copyist" 3 | version = "0.1.3" 4 | description = "Tool for precise and efficient django model copying" 5 | authors = ["abondar"] 6 | license = "Apache-2.0" 7 | readme = "README.rst" 8 | homepage = "https://github.com/abondar/django-copyist" 9 | repository = "https://github.com/abondar/django-copyist.git" 10 | documentation = "https://abondar.github.io/django-copyist/" 11 | keywords = ["django", "copy", "clone", "model"] 12 | packages = [ 13 | { include = "django_copyist" } 14 | ] 15 | include = ["CHANGELOG.rst", "LICENSE", "README.rst"] 16 | 17 | [tool.poetry.dependencies] 18 | python = "^3.11" 19 | Django = ">=3.2" 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | pre-commit = "^3.7.0" 23 | pytest = "^8.1.1" 24 | factory-boy = "^3.3.0" 25 | pytest-django = "^4.8.0" 26 | sphinx = "^7.3.7" 27 | sphinx-book-theme = "^1.1.2" 28 | enum-tools = {extras = ["sphinx"], version = "^0.12.0"} 29 | 30 | [build-system] 31 | requires = ["poetry-core"] 32 | build-backend = "poetry.core.masonry.api" 33 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = example.settings --------------------------------------------------------------------------------