├── tests ├── __init__.py ├── test_app │ ├── __init__.py │ ├── cms_plugins.py │ └── models.py ├── requirements │ ├── dj42_cms41.txt │ ├── dj42_cms50.txt │ ├── dj50_cms41.txt │ ├── dj50_cms50.txt │ ├── dj51_cms41.txt │ ├── dj51_cms50.txt │ ├── dj52_cms41.txt │ ├── dj52_cms50.txt │ └── base.txt ├── templates │ ├── page.html │ └── base.html ├── urls.py ├── test_migrations.py ├── conftest.py ├── test_export.py ├── test_helper.py ├── settings.py ├── abstract.py ├── test_toolbar.py ├── test_import.py ├── test_forms.py └── test_plugins.py ├── preview.gif ├── setup.py ├── djangocms_transfer ├── compat.py ├── locale │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── apps.py ├── templates │ └── djangocms_transfer │ │ ├── placeholder_close_frame.html │ │ └── import_plugins.html ├── __init__.py ├── utils.py ├── importer.py ├── cms_toolbars.py ├── exporter.py ├── static │ └── djangocms_transfer │ │ └── css │ │ └── transfer.css ├── datastructures.py ├── cms_plugins.py └── forms.py ├── addon.json ├── aldryn_config.py ├── MANIFEST.in ├── tools └── black ├── .tx └── config ├── .coveragerc ├── .gitignore ├── .editorconfig ├── .github ├── workflows │ ├── lint.yml │ ├── publish-to-live-pypi.yml │ ├── publish-to-test-pypi.yml │ ├── codeql.yml │ └── test.yml └── PULL_REQUEST_TEMPLATE.md ├── tox.ini ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── CHANGELOG.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-transfer/HEAD/preview.gif -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /tests/requirements/dj42_cms41.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=4.2,<5.0 4 | django-cms>=4.1,<4.2 5 | -------------------------------------------------------------------------------- /tests/requirements/dj42_cms50.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=4.2,<5.0 4 | django-cms>=5.0,<5.1 5 | -------------------------------------------------------------------------------- /tests/requirements/dj50_cms41.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=5.0,<5.1 4 | django-cms>=4.1,<4.2 5 | -------------------------------------------------------------------------------- /tests/requirements/dj50_cms50.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=5.0,<5.1 4 | django-cms>=5.0,<5.1 5 | -------------------------------------------------------------------------------- /tests/requirements/dj51_cms41.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=5.1,<5.2 4 | django-cms>=4.1,<4.2 5 | -------------------------------------------------------------------------------- /tests/requirements/dj51_cms50.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=5.1,<5.2 4 | django-cms>=5.0,<5.1 5 | -------------------------------------------------------------------------------- /tests/requirements/dj52_cms41.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=5.2,<5.3 4 | django-cms>=4.1,<4.2 5 | -------------------------------------------------------------------------------- /tests/requirements/dj52_cms50.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=5.2,<5.3 4 | django-cms>=5.0,<5.1 5 | -------------------------------------------------------------------------------- /djangocms_transfer/compat.py: -------------------------------------------------------------------------------- 1 | import cms 2 | from packaging.version import Version 3 | 4 | cms_version = Version(cms.__version__) 5 | -------------------------------------------------------------------------------- /addon.json: -------------------------------------------------------------------------------- 1 | { 2 | "package-name": "djangocms-transfer", 3 | "installed-apps": [ 4 | "djangocms_transfer" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /djangocms_transfer/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-transfer/HEAD/djangocms_transfer/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /djangocms_transfer/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-transfer/HEAD/djangocms_transfer/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /djangocms_transfer/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-transfer/HEAD/djangocms_transfer/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /djangocms_transfer/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-transfer/HEAD/djangocms_transfer/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /aldryn_config.py: -------------------------------------------------------------------------------- 1 | from aldryn_client import forms 2 | 3 | 4 | class Form(forms.BaseForm): 5 | def to_settings(self, data, settings): 6 | return settings 7 | -------------------------------------------------------------------------------- /tests/templates/page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load cms_tags %} 3 | 4 | {% block content %} 5 | {% placeholder "content" %} 6 | {% endblock content %} 7 | -------------------------------------------------------------------------------- /tests/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block content %} 7 | {% endblock %} 8 | 9 | 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include djangocms_transfer/static * 4 | recursive-include djangocms_transfer/templates * 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /tests/requirements/base.txt: -------------------------------------------------------------------------------- 1 | # other requirements 2 | tox 3 | coverage 4 | black 5 | freezegun 6 | djangocms-text 7 | pytest 8 | pytest-django 9 | 10 | # for testing purposes 11 | djangocms-link 12 | -------------------------------------------------------------------------------- /djangocms_transfer/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class TranferConfig(AppConfig): 6 | name = "djangocms_transfer" 7 | verbose_name = _("django CMS Transfer") 8 | -------------------------------------------------------------------------------- /djangocms_transfer/templates/djangocms_transfer/placeholder_close_frame.html: -------------------------------------------------------------------------------- 1 | 4 |
5 | -------------------------------------------------------------------------------- /tools/black: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | root_dir=$(readlink -f "$(dirname $0)/..") 4 | 5 | black --target-version py310 \ 6 | --line-length 119 \ 7 | $@ \ 8 | $root_dir/djangocms_transfer \ 9 | $root_dir/tests \ 10 | $root_dir/tools 11 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [djangocms-transfer.djangocms_transfer] 5 | file_filter = djangocms_transfer/locale//LC_MESSAGES/django.po 6 | source_file = djangocms_transfer/locale/en/LC_MESSAGES/django.po 7 | source_lang = en 8 | type = PO 9 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.i18n import i18n_patterns 2 | from django.contrib import admin 3 | from django.urls import include, path, re_path 4 | 5 | i18n_urls = [ 6 | re_path(r"^admin/", admin.site.urls), 7 | path("", include("cms.urls")), 8 | ] 9 | 10 | urlpatterns = i18n_patterns(*i18n_urls) 11 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = djangocms_transfer 4 | omit = 5 | migrations/* 6 | tests/* 7 | 8 | [report] 9 | exclude_lines = 10 | pragma: no cover 11 | def __repr__ 12 | if self.debug: 13 | if settings.DEBUG 14 | raise AssertionError 15 | raise NotImplementedError 16 | if 0: 17 | if __name__ == .__main__.: 18 | ignore_errors = True 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *$py.class 3 | *.egg-info 4 | *.log 5 | *.pot 6 | .DS_Store 7 | .coverage 8 | .coverage/ 9 | .eggs/ 10 | .idea/ 11 | .project/ 12 | .pydevproject/ 13 | .vscode/ 14 | .settings/ 15 | .tox/ 16 | __pycache__/ 17 | build/ 18 | dist/ 19 | env/ 20 | .venv/ 21 | 22 | /~ 23 | /node_modules 24 | .sass-cache 25 | *.css.map 26 | npm-debug.log 27 | package-lock.json 28 | 29 | local.sqlite 30 | 31 | # vi 32 | *.swp 33 | -------------------------------------------------------------------------------- /tests/test_app/cms_plugins.py: -------------------------------------------------------------------------------- 1 | from cms.plugin_base import CMSPluginBase 2 | from cms.plugin_pool import plugin_pool 3 | 4 | from .models import Article, ArticlePluginModel 5 | 6 | 7 | class RandomPlugin(CMSPluginBase): 8 | model = Article 9 | render_plugin = False 10 | 11 | 12 | class ArticlePlugin(CMSPluginBase): 13 | model = ArticlePluginModel 14 | render_plugin = False 15 | 16 | 17 | plugin_pool.register_plugin(RandomPlugin) 18 | plugin_pool.register_plugin(ArticlePlugin) 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | max_line_length = 80 13 | 14 | [*.py] 15 | max_line_length = 120 16 | quote_type = single 17 | 18 | [*.{scss,js,html}] 19 | max_line_length = 120 20 | indent_style = space 21 | quote_type = double 22 | 23 | [*.js] 24 | max_line_length = 120 25 | quote_type = single 26 | 27 | [*.rst] 28 | max_line_length = 80 29 | 30 | [*.yml] 31 | indent_size = 2 32 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | ruff: 11 | name: ruff 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.12" 20 | cache: "pip" 21 | - run: | 22 | python -m pip install --upgrade pip 23 | pip install ruff 24 | - name: Run Ruff 25 | run: ruff check djangocms_transfer tests 26 | -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | from cms.models import CMSPlugin 2 | from django.db import models 3 | 4 | 5 | class Article(CMSPlugin): 6 | title = models.CharField(max_length=50) 7 | section = models.ForeignKey('Section', on_delete=models.CASCADE) 8 | 9 | def __str__(self): 10 | return f"{self.title} -- {self.section}" 11 | 12 | 13 | class Section(models.Model): 14 | name = models.CharField(max_length=50) 15 | 16 | def __str__(self): 17 | return self.name 18 | 19 | 20 | class ArticlePluginModel(CMSPlugin): 21 | title = models.CharField(max_length=50) 22 | sections = models.ManyToManyField('Section') 23 | 24 | def __str__(self): 25 | return self.title 26 | -------------------------------------------------------------------------------- /djangocms_transfer/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.0.1" 2 | 3 | default_app_config = "djangocms_transfer.apps.TranferConfig" 4 | 5 | 6 | def get_serializer_name(default="python"): 7 | from django.conf import settings 8 | 9 | return getattr(settings, "DJANGO_CMS_TRANSFER_SERIALIZER", default) 10 | 11 | 12 | def custom_process_hook(transfer_hook, plugin, plugin_data): 13 | from importlib import import_module 14 | 15 | from django.conf import settings 16 | 17 | hook = getattr(settings, transfer_hook, None) 18 | if not hook: 19 | return plugin_data 20 | 21 | module, function = hook.rsplit(".", 1) 22 | try: 23 | func = getattr(import_module(module), function) 24 | except (ImportError, AttributeError) as e: 25 | raise ImportError(f"Could not import '{hook}': {e}") 26 | 27 | return func(plugin, plugin_data) 28 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-live-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to pypi 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish Python 🐍 distributions 📦 to pypi 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 3.12 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.12' 18 | 19 | - name: Install pypa/build 20 | run: >- 21 | python -m 22 | pip install 23 | build 24 | --user 25 | - name: Build a binary wheel and a source tarball 26 | run: >- 27 | python -m 28 | build 29 | --sdist 30 | --wheel 31 | --outdir dist/ 32 | . 33 | 34 | - name: Publish distribution 📦 to PyPI 35 | if: startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | # original from 2 | # http://tech.octopus.energy/news/2016/01/21/testing-for-missing-migrations-in-django.html 3 | from io import StringIO 4 | 5 | from django.core.management import call_command 6 | from django.test import TestCase, override_settings 7 | 8 | 9 | class MigrationTestCase(TestCase): 10 | @override_settings(MIGRATION_MODULES={"djangocms_link": None}, DEFAULT_AUTO_FIELD="django.db.models.AutoField") 11 | def test_for_missing_migrations(self): 12 | output = StringIO() 13 | options = { 14 | "interactive": False, 15 | "dry_run": True, 16 | "stdout": output, 17 | "check_changes": True, 18 | } 19 | 20 | try: 21 | call_command("makemigrations", "djangocms_transfer", **options) 22 | except SystemExit as e: 23 | status_code = str(e) 24 | else: 25 | # the "no changes" exit code is 0 26 | status_code = "0" 27 | 28 | if status_code == '1': 29 | self.fail(f'There are missing migrations:\n {output.getvalue()}') 30 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-test-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to TestPyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish Python 🐍 distributions 📦 to TestPyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Set up Python 3.12 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.12' 18 | 19 | - name: Install pypa/build 20 | run: >- 21 | python -m 22 | pip install 23 | build 24 | --user 25 | - name: Build a binary wheel and a source tarball 26 | run: >- 27 | python -m 28 | build 29 | --sdist 30 | --wheel 31 | --outdir dist/ 32 | . 33 | 34 | - name: Publish distribution 📦 to Test PyPI 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | with: 37 | user: __token__ 38 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 39 | repository_url: https://test.pypi.org/legacy/ 40 | skip_existing: true 41 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "36 2 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript, python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | if: ${{ matrix.language == 'javascript' || matrix.language == 'python' }} 38 | 39 | - name: Perform CodeQL Analysis 40 | uses: github/codeql-action/analyze@v2 41 | with: 42 | category: "/language:${{ matrix.language }}" 43 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import django 5 | import pytest 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | 10 | def transfer(first, second=None): 11 | if second: 12 | return first, second 13 | return first 14 | 15 | 16 | @pytest.fixture 17 | def use_nonexistent_transfer_hook(settings): 18 | settings.DJANGOCMS_TRANSFER_PROCESS_EXPORT_PLUGIN_DATA = "a.b.c" 19 | settings.DJANGOCMS_TRANSFER_PROCESS_IMPORT_PLUGIN_DATA = "a.b.c" 20 | 21 | 22 | @pytest.fixture 23 | def use_existent_transfer_hook(settings): 24 | settings.DJANGOCMS_TRANSFER_PROCESS_EXPORT_PLUGIN_DATA = "tests.conftest.transfer" 25 | settings.DJANGOCMS_TRANSFER_PROCESS_IMPORT_PLUGIN_DATA = "tests.conftest.transfer" 26 | 27 | 28 | def pytest_configure(): 29 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 30 | django.setup() 31 | 32 | 33 | def run(path): 34 | TestRunner = get_runner(settings) 35 | test_runner = TestRunner() 36 | failures = test_runner.run_tests(path) 37 | sys.exit(bool(failures)) 38 | 39 | 40 | if __name__ == "__main__": 41 | run(sys.argv[1:]) 42 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 7 | 8 | ## Related resources 9 | 10 | 14 | 15 | * #... 16 | * #... 17 | 18 | ## Checklist 19 | 20 | 25 | 26 | * [ ] I have opened this pull request against ``master`` 27 | * [ ] I have added or modified the tests when changing logic 28 | * [ ] I have followed [the conventional commits guidelines](https://www.conventionalcommits.org/) to add meaningful information into the changelog 29 | * [ ] I have read the [contribution guidelines ](https://github.com/django-cms/django-cms/blob/develop/CONTRIBUTING.rst) and I have joined #workgroup-pr-review on 30 | [Discord](https://www.django-cms.org/discord) to find a “pr review buddy” who is going to review my pull request. 31 | -------------------------------------------------------------------------------- /djangocms_transfer/utils.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from cms.models import CMSPlugin 4 | from cms.plugin_pool import plugin_pool 5 | 6 | 7 | @lru_cache() 8 | def get_local_fields(model): 9 | opts = model._meta.concrete_model._meta 10 | fields = opts.local_fields 11 | return [ 12 | field.name 13 | for field in fields 14 | if not field.is_relation and not field.primary_key 15 | ] 16 | 17 | 18 | @lru_cache() 19 | def get_related_fields(model): 20 | opts = model._meta.concrete_model._meta 21 | fields = opts.local_fields + list(opts.many_to_many) 22 | return [field.name for field in fields if field.is_relation] 23 | 24 | 25 | @lru_cache() 26 | def get_plugin_class(plugin_type): 27 | return plugin_pool.get_plugin(plugin_type) 28 | 29 | 30 | @lru_cache() 31 | def get_plugin_fields(plugin_type): 32 | klass = get_plugin_class(plugin_type) 33 | if klass.model is CMSPlugin: 34 | return [] 35 | opts = klass.model._meta.concrete_model._meta 36 | fields = opts.local_fields + opts.local_many_to_many 37 | return [field.name for field in fields] 38 | 39 | 40 | @lru_cache() 41 | def get_plugin_model(plugin_type): 42 | return get_plugin_class(plugin_type).model 43 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39}-dj{42}-cms{41}, 4 | py{310,311,312,313}-dj{42,50,51,52}-cms{41,50} 5 | 6 | skip_missing_interpreters=True 7 | 8 | [flake8] 9 | max-line-length = 119 10 | exclude = 11 | env, 12 | *.egg-info, 13 | .eggs, 14 | .git, 15 | .settings, 16 | .tox, 17 | build, 18 | data, 19 | dist, 20 | docs, 21 | *migrations*, 22 | requirements, 23 | tmp 24 | 25 | [isort] 26 | line_length = 79 27 | skip = manage.py, *migrations*, .tox, .eggs, data, env 28 | include_trailing_comma = true 29 | multi_line_output = 5 30 | lines_after_imports = 2 31 | default_section = THIRDPARTY 32 | sections = FUTURE, STDLIB, DJANGO, CMS, THIRDPARTY, FIRSTPARTY, LOCALFOLDER 33 | known_first_party = djangocms_transfer 34 | known_cms = cms, menus 35 | known_django = django 36 | 37 | [testenv] 38 | deps = 39 | -r{toxinidir}/tests/requirements/base.txt 40 | dj42: Django>=4.2,<5.0 41 | dj50: Django>=5.0,<5.1 42 | dj51: Django>=5.1,<5.2 43 | dj52: Django>=5.2,<5.3 44 | cms41: django-cms>=4.1,<4.2 45 | cms50: django-cms>=5.0,<5.1 46 | 47 | commands = 48 | {envpython} --version 49 | {env:COMMAND:coverage} erase 50 | {env:COMMAND:coverage} run tests/settings.py 51 | {env:COMMAND:coverage} report 52 | 53 | -------------------------------------------------------------------------------- /djangocms_transfer/templates/djangocms_transfer/import_plugins.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load cms_admin cms_static i18n %} 3 | 4 | {% block content %} 5 |

{% trans "Import plugins" %}

6 | {% if form.errors %} 7 |

8 | {% blocktrans count form.errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} 9 |

10 | {{ form.non_field_errors }} 11 | {% endif %} 12 |
13 | {% csrf_token %} 14 | {% for hidden in form.hidden_fields %} 15 | {{ hidden }} 16 | {% endfor %} 17 |
18 |
19 | {% for field in form.visible_fields %} 20 |
21 | 22 | {% if field.errors %}{{ field.errors }}{% endif %} 23 | {{ field.label_tag }} 24 | {{ field }} 25 |
26 |
27 | {% endfor %} 28 | 29 | 30 |
31 | 32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: | 3 | ci: auto fixes from pre-commit hooks 4 | for more information, see https://pre-commit.ci 5 | autofix_prs: false 6 | autoupdate_commit_msg: 'ci: pre-commit autoupdate' 7 | autoupdate_schedule: monthly 8 | 9 | repos: 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v4.1.0 12 | hooks: 13 | - id: trailing-whitespace 14 | - id: end-of-file-fixer 15 | - id: check-yaml 16 | - id: check-added-large-files 17 | - id: check-merge-conflict 18 | - id: debug-statements 19 | - id: mixed-line-ending 20 | - id: trailing-whitespace 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.2.0 23 | hooks: 24 | - id: pyupgrade 25 | args: ["--py39-plus"] 26 | - repo: https://github.com/adamchainz/django-upgrade 27 | rev: '1.4.0' 28 | hooks: 29 | - id: django-upgrade 30 | args: [--target-version, "4.2"] 31 | - repo: https://github.com/PyCQA/flake8 32 | rev: 4.0.1 33 | hooks: 34 | - id: flake8 35 | - repo: https://github.com/asottile/yesqa 36 | rev: v1.3.0 37 | hooks: 38 | - id: yesqa 39 | - repo: https://github.com/pycqa/isort 40 | rev: 5.10.1 41 | hooks: 42 | - id: isort 43 | - repo: https://github.com/codespell-project/codespell 44 | rev: v2.1.0 45 | hooks: 46 | - id: codespell 47 | args: ["--ignore-words-list", "ist, oder, alle"] 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Divio AG 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of Divio AG nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL DIVIO AG BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /tests/test_export.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from djangocms_transfer.exporter import ( 4 | export_page, 5 | export_placeholder, 6 | export_plugin, 7 | ) 8 | 9 | from .abstract import FunctionalityBaseTestCase 10 | 11 | 12 | class ExportTest(FunctionalityBaseTestCase): 13 | def test_export_plugin(self): 14 | plugin = self._create_plugin() 15 | 16 | actual = json.loads(export_plugin(plugin)) 17 | 18 | self.assertEqual(self._get_expected_plugin_export_data(), actual) 19 | 20 | def test_export_placeholder(self): 21 | placeholder = self.page_content.get_placeholders().get(slot="content") 22 | 23 | with self.subTest("empty placeholder"): 24 | actual = json.loads(export_placeholder(placeholder, "en")) 25 | self.assertEqual([], actual) 26 | 27 | with self.subTest("placeholder with plugin"): 28 | self._create_plugin() 29 | actual = json.loads(export_placeholder(placeholder, "en")) 30 | self.assertEqual(self._get_expected_placeholder_export_data(), actual) 31 | 32 | def test_export_page(self): 33 | with self.subTest("empty page"): 34 | actual = json.loads(export_page(self.page_content, "en")) 35 | self.assertEqual([{"placeholder": "content", "plugins": []}], actual) 36 | 37 | with self.subTest("page with plugin"): 38 | self._create_plugin() 39 | actual = json.loads(export_page(self.page_content, "en")) 40 | self.assertEqual(self._get_expected_page_export_data(), actual) 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CodeCov 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | unit-tests: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13"] 12 | requirements-file: [ 13 | dj42_cms41.txt, 14 | dj50_cms41.txt, 15 | dj51_cms41.txt, 16 | dj52_cms41.txt, 17 | dj42_cms50.txt, 18 | dj50_cms50.txt, 19 | dj51_cms50.txt, 20 | dj52_cms50.txt, 21 | ] 22 | os: [ 23 | ubuntu-latest, 24 | ] 25 | exclude: 26 | - requirements-file: dj50_cms41.txt 27 | python-version: 3.9 28 | - requirements-file: dj51_cms41.txt 29 | python-version: 3.9 30 | - requirements-file: dj52_cms41.txt 31 | python-version: 3.9 32 | - requirements-file: dj50_cms50.txt 33 | python-version: 3.9 34 | - requirements-file: dj51_cms50.txt 35 | python-version: 3.9 36 | - requirements-file: dj52_cms50.txt 37 | python-version: 3.9 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Set up Python ${{ matrix.python-version }} 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install -r tests/requirements/${{ matrix.requirements-file }} 48 | python setup.py install 49 | 50 | - name: Run coverage 51 | run: coverage run -m pytest 52 | 53 | - name: Upload Coverage to Codecov 54 | uses: codecov/codecov-action@v5 55 | with: 56 | token: ${{ secrets.CODECOV_TOKEN }} 57 | -------------------------------------------------------------------------------- /djangocms_transfer/importer.py: -------------------------------------------------------------------------------- 1 | from cms.models import CMSPlugin 2 | from django.db import transaction 3 | 4 | 5 | @transaction.atomic 6 | def import_plugins(plugins, placeholder, language, root_plugin_id=None): 7 | source_map = {} 8 | new_plugins = [] 9 | 10 | if root_plugin_id: 11 | root_plugin = CMSPlugin.objects.get(pk=root_plugin_id) 12 | source_map[root_plugin_id] = root_plugin 13 | else: 14 | root_plugin = None 15 | 16 | for archived_plugin in plugins: 17 | # custom handling via "get_plugin_data" can lead to "null"-values 18 | # instead of plugin-dictionaries. We skip those here. 19 | if archived_plugin is None: 20 | continue 21 | 22 | if archived_plugin.parent_id: 23 | parent = source_map[archived_plugin.parent_id] 24 | else: 25 | parent = root_plugin 26 | 27 | if parent and parent.__class__ != CMSPlugin: 28 | parent = parent.cmsplugin_ptr 29 | 30 | plugin = archived_plugin.restore( 31 | placeholder=placeholder, 32 | language=language, 33 | parent=parent, 34 | ) 35 | source_map[archived_plugin.pk] = plugin 36 | 37 | new_plugins.append((plugin, archived_plugin)) 38 | 39 | for new_plugin, _ in new_plugins: 40 | # Replace all internal child plugins with their new ids 41 | new_plugin.post_copy(new_plugin, new_plugins) 42 | 43 | 44 | @transaction.atomic 45 | def import_plugins_to_page(placeholders, pagecontent, language): 46 | page_placeholders = pagecontent.rescan_placeholders() 47 | 48 | for archived_placeholder in placeholders: 49 | plugins = archived_placeholder.plugins 50 | placeholder = page_placeholders.get(archived_placeholder.slot) 51 | 52 | if placeholder and plugins: 53 | import_plugins(plugins, placeholder, language) 54 | -------------------------------------------------------------------------------- /djangocms_transfer/cms_toolbars.py: -------------------------------------------------------------------------------- 1 | from cms.models import PageContent 2 | from cms.toolbar_base import CMSToolbar 3 | from cms.toolbar_pool import toolbar_pool 4 | from cms.utils.page_permissions import user_can_change_page 5 | from cms.utils.urlutils import admin_reverse 6 | from django.utils.http import urlencode 7 | from django.utils.translation import gettext 8 | 9 | 10 | @toolbar_pool.register 11 | class PluginImporter(CMSToolbar): 12 | class Media: 13 | css = {"all": ("djangocms_transfer/css/transfer.css",)} 14 | 15 | def populate(self): 16 | # always use draft if we have a page 17 | page = getattr(self.request, "current_page", None) 18 | if not page: 19 | return 20 | 21 | if not user_can_change_page(self.request.user, page): 22 | return 23 | 24 | page_menu = self.toolbar.get_menu("page") 25 | 26 | if not page_menu or page_menu.disabled: 27 | return 28 | 29 | obj = self.toolbar.get_object() 30 | if not obj: 31 | return 32 | if not isinstance(obj, PageContent): 33 | return 34 | 35 | data = urlencode( 36 | { 37 | "language": self.current_lang, 38 | "cms_pagecontent": obj.pk, 39 | } 40 | ) 41 | 42 | not_edit_mode = not self.toolbar.edit_mode_active 43 | 44 | page_menu.add_break("Page menu importer break") 45 | page_menu.add_link_item( 46 | gettext("Export"), 47 | url=admin_reverse("cms_export_plugins") + "?" + data, 48 | disabled=not_edit_mode, 49 | ) 50 | page_menu.add_modal_item( 51 | gettext("Import"), 52 | url=admin_reverse("cms_import_plugins") + "?" + data, 53 | disabled=not_edit_mode, 54 | on_close=getattr(self.toolbar, "request_path", self.request.path), 55 | ) 56 | -------------------------------------------------------------------------------- /tests/test_helper.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from djangocms_transfer import custom_process_hook 4 | 5 | from .abstract import FunctionalityBaseTestCase 6 | 7 | 8 | class CustomProcessHookTest(FunctionalityBaseTestCase): 9 | def setUp(self): 10 | super().setUp() 11 | self.plugin = self._create_plugin() 12 | self.plugin_data = self._get_expected_page_export_data 13 | 14 | def test_empty_nonexistent_custom_process_hook(self): 15 | self.assertEqual( 16 | custom_process_hook("", self.plugin, self.plugin_data), 17 | self.plugin_data 18 | ) 19 | self.assertEqual( 20 | custom_process_hook("UNKNOWN_TRANSFER", self.plugin, self.plugin_data), 21 | self.plugin_data 22 | ) 23 | 24 | @pytest.mark.usefixtures("use_nonexistent_transfer_hook") 25 | def test_nonexistent_module(self): 26 | self.assertRaises( 27 | ImportError, 28 | custom_process_hook, 29 | "DJANGOCMS_TRANSFER_PROCESS_IMPORT_PLUGIN_DATA", 30 | self.plugin, self.plugin_data 31 | ) 32 | self.assertRaises( 33 | ImportError, 34 | custom_process_hook, 35 | "DJANGOCMS_TRANSFER_PROCESS_EXPORT_PLUGIN_DATA", 36 | self.plugin, self.plugin_data 37 | ) 38 | 39 | @pytest.mark.usefixtures("use_existent_transfer_hook") 40 | def test_existent_module(self): 41 | self.assertTrue( 42 | custom_process_hook( 43 | "DJANGOCMS_TRANSFER_PROCESS_IMPORT_PLUGIN_DATA", 44 | self.plugin, {} 45 | ), 46 | (self.plugin, {}) 47 | ) 48 | self.assertTrue( 49 | custom_process_hook( 50 | "DJANGOCMS_TRANSFER_PROCESS_EXPORT_PLUGIN_DATA", 51 | self.plugin, self.plugin_data 52 | ), 53 | (self.plugin, self.plugin_data) 54 | ) 55 | -------------------------------------------------------------------------------- /djangocms_transfer/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2019-01-22 11:52+0100\n" 12 | "PO-Revision-Date: 2019-01-22 10:49+0000\n" 13 | "Language-Team: French (https://www.transifex.com/divio/teams/58664/fr/)\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Language: fr\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | 20 | #: djangocms_transfer/apps.py:10 21 | msgid "django CMS Transfer" 22 | msgstr "" 23 | 24 | #: djangocms_transfer/cms_plugins.py:53 djangocms_transfer/cms_plugins.py:80 25 | msgid "Export plugins" 26 | msgstr "" 27 | 28 | #: djangocms_transfer/cms_plugins.py:62 djangocms_transfer/cms_plugins.py:89 29 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:5 30 | msgid "Import plugins" 31 | msgstr "" 32 | 33 | #: djangocms_transfer/cms_plugins.py:112 djangocms_transfer/cms_plugins.py:178 34 | msgid "Form received unexpected values." 35 | msgstr "" 36 | 37 | #: djangocms_transfer/cms_toolbars.py:50 38 | msgid "Export" 39 | msgstr "" 40 | 41 | #: djangocms_transfer/cms_toolbars.py:55 42 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:31 43 | msgid "Import" 44 | msgstr "" 45 | 46 | #: djangocms_transfer/forms.py:69 47 | msgid "A plugin, placeholder or page is required." 48 | msgstr "" 49 | 50 | #: djangocms_transfer/forms.py:73 djangocms_transfer/forms.py:77 51 | #: djangocms_transfer/forms.py:81 52 | msgid "" 53 | "Plugins can be imported to pages, plugins or placeholders. Not all three." 54 | msgstr "" 55 | 56 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:8 57 | msgid "Please correct the error below." 58 | msgid_plural "Please correct the errors below." 59 | msgstr[0] "" 60 | msgstr[1] "" 61 | -------------------------------------------------------------------------------- /djangocms_transfer/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2019-01-22 11:52+0100\n" 12 | "PO-Revision-Date: 2019-01-22 10:49+0000\n" 13 | "Language-Team: Spanish (https://www.transifex.com/divio/teams/58664/es/)\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Language: es\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: djangocms_transfer/apps.py:10 21 | msgid "django CMS Transfer" 22 | msgstr "" 23 | 24 | #: djangocms_transfer/cms_plugins.py:53 djangocms_transfer/cms_plugins.py:80 25 | msgid "Export plugins" 26 | msgstr "" 27 | 28 | #: djangocms_transfer/cms_plugins.py:62 djangocms_transfer/cms_plugins.py:89 29 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:5 30 | msgid "Import plugins" 31 | msgstr "" 32 | 33 | #: djangocms_transfer/cms_plugins.py:112 djangocms_transfer/cms_plugins.py:178 34 | msgid "Form received unexpected values." 35 | msgstr "" 36 | 37 | #: djangocms_transfer/cms_toolbars.py:50 38 | msgid "Export" 39 | msgstr "" 40 | 41 | #: djangocms_transfer/cms_toolbars.py:55 42 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:31 43 | msgid "Import" 44 | msgstr "" 45 | 46 | #: djangocms_transfer/forms.py:69 47 | msgid "A plugin, placeholder or page is required." 48 | msgstr "" 49 | 50 | #: djangocms_transfer/forms.py:73 djangocms_transfer/forms.py:77 51 | #: djangocms_transfer/forms.py:81 52 | msgid "" 53 | "Plugins can be imported to pages, plugins or placeholders. Not all three." 54 | msgstr "" 55 | 56 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:8 57 | msgid "Please correct the error below." 58 | msgid_plural "Please correct the errors below." 59 | msgstr[0] "" 60 | msgstr[1] "" 61 | -------------------------------------------------------------------------------- /djangocms_transfer/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2019-01-22 11:52+0100\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: djangocms_transfer/apps.py:10 22 | msgid "django CMS Transfer" 23 | msgstr "" 24 | 25 | #: djangocms_transfer/cms_plugins.py:53 djangocms_transfer/cms_plugins.py:80 26 | msgid "Export plugins" 27 | msgstr "" 28 | 29 | #: djangocms_transfer/cms_plugins.py:62 djangocms_transfer/cms_plugins.py:89 30 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:5 31 | msgid "Import plugins" 32 | msgstr "" 33 | 34 | #: djangocms_transfer/cms_plugins.py:112 djangocms_transfer/cms_plugins.py:178 35 | msgid "Form received unexpected values." 36 | msgstr "" 37 | 38 | #: djangocms_transfer/cms_toolbars.py:50 39 | msgid "Export" 40 | msgstr "" 41 | 42 | #: djangocms_transfer/cms_toolbars.py:55 43 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:31 44 | msgid "Import" 45 | msgstr "" 46 | 47 | #: djangocms_transfer/forms.py:69 48 | msgid "A plugin, placeholder or page is required." 49 | msgstr "" 50 | 51 | #: djangocms_transfer/forms.py:73 djangocms_transfer/forms.py:77 52 | #: djangocms_transfer/forms.py:81 53 | msgid "" 54 | "Plugins can be imported to pages, plugins or placeholders. Not all three." 55 | msgstr "" 56 | 57 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:8 58 | msgid "Please correct the error below." 59 | msgid_plural "Please correct the errors below." 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | SECRET_KEY = "djangocms-transfer-test" 4 | 5 | ALLOWED_HOSTS = ["localhost"] 6 | 7 | INSTALLED_APPS = [ 8 | "django.contrib.contenttypes", 9 | "django.contrib.auth", 10 | "django.contrib.sites", 11 | "django.contrib.sessions", 12 | "django.contrib.admin", 13 | "django.contrib.messages", 14 | 15 | "cms", 16 | "menus", 17 | "treebeard", 18 | "djangocms_text", 19 | "djangocms_link", 20 | "djangocms_transfer", 21 | "tests.test_app", 22 | ] 23 | 24 | MIDDLEWARE = [ 25 | "django.contrib.sessions.middleware.SessionMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.messages.middleware.MessageMiddleware", 28 | "cms.middleware.toolbar.ToolbarMiddleware" 29 | ] 30 | 31 | TEMPLATES = [ 32 | { 33 | "BACKEND": "django.template.backends.django.DjangoTemplates", 34 | "DIRS": [ 35 | os.path.join((os.path.dirname(__file__)), "templates"), 36 | ], 37 | "APP_DIRS": True, 38 | "OPTIONS": { 39 | "context_processors": [ 40 | "django.template.context_processors.debug", 41 | "django.template.context_processors.request", 42 | "django.contrib.auth.context_processors.auth", 43 | "django.contrib.messages.context_processors.messages", 44 | ], 45 | }, 46 | }, 47 | ] 48 | 49 | SITE_ID = 1 50 | 51 | CMS_TEMPLATES = ( 52 | ("page.html", "Normal page"), 53 | ) 54 | 55 | CMS_LANGUAGES = { 56 | 1: [{ 57 | "code": "en", 58 | "name": "English", 59 | }] 60 | } 61 | 62 | DATABASES = { 63 | "default": { 64 | "ENGINE": "django.db.backends.sqlite3", 65 | "NAME": ":memory:", 66 | "TEST": { 67 | # disable migrations when creating test database 68 | "MIGRATE": False, 69 | }, 70 | } 71 | } 72 | 73 | CMS_CONFIRM_VERSION4 = True 74 | 75 | USE_TZ = True 76 | 77 | LANGUAGE_CODE = "en" 78 | 79 | ROOT_URLCONF = "tests.urls" 80 | 81 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 82 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "djangocms-transfer" 7 | description = "Adds import and export of plugin data." 8 | dependencies = [ 9 | "django-cms>=4.1", 10 | ] 11 | dynamic = [ "version" ] 12 | readme = "README.rst" 13 | requires-python = ">=3.9" 14 | license = {text = "BSD-3-Clause"} 15 | authors = [ 16 | {name = "Divio AG", email = "info@divio.ch"}, 17 | ] 18 | maintainers = [ 19 | {name = "Django CMS Association and contributors", email = "info@django-cms.org"}, 20 | ] 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Environment :: Web Environment", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: BSD License", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | "Framework :: Django", 34 | "Framework :: Django :: 4.2", 35 | "Framework :: Django :: 5.0", 36 | "Framework :: Django :: 5.1", 37 | "Framework :: Django :: 5.2", 38 | "Framework :: Django CMS", 39 | "Framework :: Django CMS :: 4.1", 40 | "Framework :: Django CMS :: 5.0", 41 | "Topic :: Internet :: WWW/HTTP", 42 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 43 | "Topic :: Software Development", 44 | "Topic :: Software Development :: Libraries", 45 | ] 46 | 47 | [project.urls] 48 | Homepage = "https://github.com/django-cms/djangocms-transfer" 49 | 50 | [tool.setuptools] 51 | packages = [ "djangocms_transfer" ] 52 | 53 | [tool.setuptools.dynamic] 54 | version = { attr = "djangocms_transfer.__version__" } 55 | 56 | [tool.ruff] 57 | lint.exclude = [ 58 | ".env", 59 | ".venv", 60 | "**/migrations/**", 61 | ] 62 | lint.ignore = [ 63 | "E501", # line too long 64 | "F403", # 'from module import *' used; unable to detect undefined names 65 | "E701", # multiple statements on one line (colon) 66 | "F401", # module imported but unused 67 | ] 68 | line-length = 119 69 | lint.select = [ 70 | "I", 71 | "E", 72 | "F", 73 | "W", 74 | ] 75 | 76 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 2.0.1 (2025-11-10) 6 | ================== 7 | 8 | * pre-commit hook updates by @bencipher in https://github.com/django-cms/djangocms-transfer/pull/25 9 | * Update the pks for internal child plugins by @mrbazzan in https://github.com/django-cms/djangocms-transfer/pull/49 10 | * django-cms 5.0 compatibility & restored import callback invocation by @florianschieder in https://github.com/django-cms/djangocms-transfer/pull/46 11 | 12 | **New Contributors** 13 | 14 | * @bencipher made their first contribution in https://github.com/django-cms/djangocms-transfer/pull/25 15 | * @florianschieder made their first contribution in https://github.com/django-cms/djangocms-transfer/pull/46 16 | 17 | 18 | 2.0.0 (2025-08-09) 19 | ================== 20 | 21 | * Add support for django CMS 4 22 | * Drop support for django CMS 3.7 - 3.11 23 | * Add support for Django 4.2, 5.0 and 5.1 24 | 25 | 26 | 1.0.2 (2024-02-29) 27 | ================== 28 | 29 | Changes 30 | ------- 31 | 32 | * make export filenames a bit more unique by @arneb in https://github.com/django-cms/djangocms-transfer/pull/15 33 | * added customization of export/import data via user defined functions by @wfehr in https://github.com/django-cms/djangocms-transfer/pull/20 34 | * Fix error on export when getting filename by @wfehr in https://github.com/django-cms/djangocms-transfer/pull/33 35 | * Improved testing environment and fixed existing errors by @wfehr in https://github.com/django-cms/djangocms-transfer/pull/35 36 | 37 | New Contributors 38 | ---------------- 39 | 40 | * @wfehr made their first contribution in https://github.com/django-cms/djangocms-transfer/pull/20 41 | 42 | 43 | 1.0.1 (2023-03-13) 44 | ================== 45 | 46 | * Added support for Django 3.2 and 4.1 47 | * Added support for django up to CMS 3.11 48 | * Serializer made configurable through Django setting DJANGO_CMS_TRANSFER_SERIALIZER 49 | 50 | 1.0.0 (2020-09-02) 51 | ================== 52 | 53 | * Added support for Django 3.1 54 | * Dropped support for Python 2.7 and Python 3.4 55 | * Dropped support for Django < 2.2 56 | 57 | 58 | 0.3.0 (2020-04-23) 59 | ================== 60 | 61 | * Added support for Django 3.0 62 | * Added support for Python 3.8 63 | 64 | 65 | 0.2.0 (2019-05-23) 66 | ================== 67 | 68 | * Added support for Django 2.2 and django CMS 3.7 69 | * Removed support for Django 2.0 70 | * Extended test matrix 71 | * Added isort and adapted imports 72 | * Adapted code base to align with other supported addons 73 | 74 | 75 | 0.1.0 (2018-12-18) 76 | ================== 77 | 78 | * Public release 79 | -------------------------------------------------------------------------------- /djangocms_transfer/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | # Translators: 7 | # Angelo Dini , 2019 8 | # 9 | #, fuzzy 10 | msgid "" 11 | msgstr "" 12 | "Project-Id-Version: PACKAGE VERSION\n" 13 | "Report-Msgid-Bugs-To: \n" 14 | "POT-Creation-Date: 2019-01-22 11:52+0100\n" 15 | "PO-Revision-Date: 2019-01-22 10:49+0000\n" 16 | "Last-Translator: Angelo Dini , 2019\n" 17 | "Language-Team: German (https://www.transifex.com/divio/teams/58664/de/)\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Language: de\n" 22 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 23 | 24 | #: djangocms_transfer/apps.py:10 25 | msgid "django CMS Transfer" 26 | msgstr "django CMS Transfer" 27 | 28 | #: djangocms_transfer/cms_plugins.py:53 djangocms_transfer/cms_plugins.py:80 29 | msgid "Export plugins" 30 | msgstr "Plugins exportieren" 31 | 32 | #: djangocms_transfer/cms_plugins.py:62 djangocms_transfer/cms_plugins.py:89 33 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:5 34 | msgid "Import plugins" 35 | msgstr "Plugins importieren" 36 | 37 | #: djangocms_transfer/cms_plugins.py:112 djangocms_transfer/cms_plugins.py:178 38 | msgid "Form received unexpected values." 39 | msgstr "Das Formular hat unerwartete Inhalte erhalten." 40 | 41 | #: djangocms_transfer/cms_toolbars.py:50 42 | msgid "Export" 43 | msgstr "Exportieren" 44 | 45 | #: djangocms_transfer/cms_toolbars.py:55 46 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:31 47 | msgid "Import" 48 | msgstr "Importieren" 49 | 50 | #: djangocms_transfer/forms.py:69 51 | msgid "A plugin, placeholder or page is required." 52 | msgstr "Ein Plugin, Platzhalter oder Seite ist erforderlich." 53 | 54 | #: djangocms_transfer/forms.py:73 djangocms_transfer/forms.py:77 55 | #: djangocms_transfer/forms.py:81 56 | msgid "" 57 | "Plugins can be imported to pages, plugins or placeholders. Not all three." 58 | msgstr "" 59 | "Plugins können in Seiten, Plugins oder Platzhalter importiert werden. " 60 | "Allerdings nicht in alle drei gleichzeitig." 61 | 62 | #: djangocms_transfer/templates/djangocms_transfer/import_plugins.html:8 63 | msgid "Please correct the error below." 64 | msgid_plural "Please correct the errors below." 65 | msgstr[0] "Bitte korrigiere den unten aufgeführten Fehler." 66 | msgstr[1] "Bitte korrigiere die unten aufgeführten Fehler." 67 | -------------------------------------------------------------------------------- /tests/abstract.py: -------------------------------------------------------------------------------- 1 | from cms.api import add_plugin, create_page 2 | from cms.test_utils.testcases import CMSTestCase 3 | from freezegun import freeze_time 4 | 5 | 6 | @freeze_time("2024-02-28 00:00:00") 7 | class FunctionalityBaseTestCase(CMSTestCase): 8 | 9 | TEXT_BODY = "Hello World!" 10 | 11 | def setUp(self): 12 | self.page = self._create_page() 13 | self.page_content = self.page.pagecontent_set(manager="admin_manager").filter(language="en").first() 14 | self.page.set_as_homepage() 15 | 16 | def _create_plugin(self, plugin_type="TextPlugin", parent=None, **kwargs): 17 | if plugin_type == "TextPlugin": 18 | kwargs["body"] = self.TEXT_BODY 19 | elif plugin_type == "LinkPlugin": 20 | kwargs["name"] = plugin_type.lower() 21 | kwargs["link"] = {"external_link": "https://www.django-cms.org"} 22 | 23 | return self._add_plugin_to_page( 24 | plugin_type, 25 | "last-child", 26 | parent, 27 | **kwargs 28 | ) 29 | 30 | def _create_page(self, **kwargs): 31 | if "template" not in kwargs: 32 | kwargs["template"] = "page.html" 33 | if "title" not in kwargs: 34 | kwargs["title"] = "Home" 35 | if "language" not in kwargs: 36 | kwargs["language"] = "en" 37 | return create_page(**kwargs) 38 | 39 | def _add_plugin_to_page(self, plugin_publisher, *args, page=None, **kwargs): 40 | if page is None: 41 | page = self.page 42 | return add_plugin( 43 | self.page_content.get_placeholders().get(slot="content"), 44 | plugin_publisher, 45 | "en", 46 | *args, 47 | **kwargs, 48 | ) 49 | 50 | def _render_page(self, page=None): 51 | if page is None: 52 | page = self.page 53 | page.publish("en") 54 | response = self.client.get(page.get_absolute_url()) 55 | return response.content.decode("utf-8") 56 | 57 | def _get_expected_plugin_export_data(self): 58 | return [ 59 | { 60 | "pk": 1, 61 | "creation_date": "2024-02-28T00:00:00Z", 62 | "position": 1, 63 | "plugin_type": "TextPlugin", 64 | "parent_id": None, 65 | "data": {'body': 'Hello World!', 'json': None, 'rte': ''}, 66 | }, 67 | ] 68 | 69 | def _get_expected_placeholder_export_data(self): 70 | return self._get_expected_plugin_export_data() 71 | 72 | def _get_expected_page_export_data(self): 73 | return [ 74 | { 75 | "placeholder": "content", 76 | "plugins": self._get_expected_plugin_export_data(), 77 | }, 78 | ] 79 | -------------------------------------------------------------------------------- /djangocms_transfer/exporter.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | 4 | from cms.utils.plugins import get_bound_plugins 5 | from django.core import serializers 6 | from django.core.serializers.json import DjangoJSONEncoder 7 | 8 | from . import custom_process_hook, get_serializer_name 9 | from .utils import get_plugin_fields 10 | 11 | dump_json = functools.partial(json.dumps, cls=DjangoJSONEncoder) 12 | 13 | 14 | def get_plugin_data(plugin, only_meta=False): 15 | if only_meta: 16 | custom_data = None 17 | else: 18 | plugin_fields = get_plugin_fields(plugin.plugin_type) 19 | _plugin_data = serializers.serialize( 20 | get_serializer_name(), (plugin,), fields=plugin_fields 21 | )[0] 22 | custom_data = _plugin_data["fields"] 23 | 24 | plugin_data = { 25 | "pk": plugin.pk, 26 | "creation_date": plugin.creation_date, 27 | "position": plugin.position, 28 | "plugin_type": plugin.plugin_type, 29 | "parent_id": plugin.parent_id, 30 | "data": custom_data, 31 | } 32 | 33 | # customize plugin-data on export 34 | return custom_process_hook( 35 | "DJANGOCMS_TRANSFER_PROCESS_EXPORT_PLUGIN_DATA", 36 | plugin, 37 | plugin_data 38 | ) 39 | 40 | 41 | def export_plugin(plugin): 42 | data = get_plugin_export_data(plugin) 43 | return dump_json(data) 44 | 45 | 46 | def export_placeholder(placeholder, language): 47 | data = get_placeholder_export_data(placeholder, language) 48 | return dump_json(data) 49 | 50 | 51 | def export_page(cms_pagecontent, language): 52 | data = get_page_export_data(cms_pagecontent, language) 53 | return dump_json(data) 54 | 55 | 56 | def get_plugin_export_data(plugin): 57 | descendants = plugin.get_descendants() 58 | plugin_data = [get_plugin_data(plugin=plugin)] 59 | plugin_data[0]["parent_id"] = None 60 | plugin_data.extend( 61 | get_plugin_data(plugin) for plugin in get_bound_plugins(descendants) 62 | ) 63 | return plugin_data 64 | 65 | 66 | def get_placeholder_export_data(placeholder, language): 67 | plugins = placeholder.get_plugins(language) 68 | # The following results in two queries; 69 | # First all the root plugins are fetched, then all child plugins. 70 | # This is needed to account for plugin path corruptions. 71 | 72 | return [get_plugin_data(plugin) for plugin in get_bound_plugins(list(plugins))] 73 | 74 | 75 | def get_page_export_data(cms_pagecontent, language): 76 | data = [] 77 | placeholders = cms_pagecontent.rescan_placeholders().values() 78 | 79 | for placeholder in list(placeholders): 80 | plugins = get_placeholder_export_data(placeholder, language) 81 | data.append({"placeholder": placeholder.slot, "plugins": plugins}) 82 | return data 83 | -------------------------------------------------------------------------------- /djangocms_transfer/static/djangocms_transfer/css/transfer.css: -------------------------------------------------------------------------------- 1 | div.cms .cms-structure .cms-submenu-item a[data-icon=import]:before, 2 | div.cms .cms-structure .cms-submenu-item a[data-icon=export]:before { 3 | position: absolute; 4 | content: ''; 5 | display: block !important; 6 | width: 16px !important; 7 | height: 16px !important; 8 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-arrow-bar-down' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1 3.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5M8 6a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 0 1 .708-.708L7.5 12.293V6.5A.5.5 0 0 1 8 6'/%3E%3C/svg%3E"); 9 | top: 15px !important; 10 | background-size: 16px 16px; 11 | overflow: hidden !important; 12 | line-height: 16px !important; 13 | min-height: 16px !important; 14 | } 15 | div.cms .cms-structure .cms-submenu-item a[data-icon=import]:before { 16 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-arrow-bar-up' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 10a.5.5 0 0 0 .5-.5V3.707l2.146 2.147a.5.5 0 0 0 .708-.708l-3-3a.5.5 0 0 0-.708 0l-3 3a.5.5 0 1 0 .708.708L7.5 3.707V9.5a.5.5 0 0 0 .5.5m-7 2.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5'/%3E%3C/svg%3E"); 17 | } 18 | div.cms .cms-structure .cms-submenu-item a[data-icon=export]:hover:before, 19 | div.cms .cms-structure .cms-submenu-item a[data-icon=export]:focus:before { 20 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-arrow-bar-down' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1 3.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5M8 6a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 0 1 .708-.708L7.5 12.293V6.5A.5.5 0 0 1 8 6'/%3E%3C/svg%3E"); 21 | } 22 | div.cms .cms-structure .cms-submenu-item a[data-icon=import]:hover:before, 23 | div.cms .cms-structure .cms-submenu-item a[data-icon=import]:focus:before { 24 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-arrow-bar-up' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 10a.5.5 0 0 0 .5-.5V3.707l2.146 2.147a.5.5 0 0 0 .708-.708l-3-3a.5.5 0 0 0-.708 0l-3 3a.5.5 0 1 0 .708.708L7.5 3.707V9.5a.5.5 0 0 0 .5.5m-7 2.5a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13a.5.5 0 0 1-.5-.5'/%3E%3C/svg%3E"); 25 | } 26 | div.cms .cms-structure.cms-structure-condensed .cms-submenu-item a[data-icon=export]:before, 27 | div.cms .cms-structure.cms-structure-condensed .cms-submenu-item a[data-icon=import]:before { 28 | top: 12px !important; 29 | } 30 | -------------------------------------------------------------------------------- /djangocms_transfer/datastructures.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from cms.api import add_plugin 4 | from cms.models import CMSPlugin 5 | from django.core.serializers import deserialize 6 | from django.db import transaction 7 | from django.utils.encoding import force_str 8 | from django.utils.functional import cached_property 9 | 10 | from . import custom_process_hook, get_serializer_name 11 | from .utils import get_plugin_model 12 | 13 | BaseArchivedPlugin = namedtuple( 14 | "ArchivedPlugin", 15 | ["pk", "creation_date", "position", "plugin_type", "parent_id", "data"], 16 | ) 17 | 18 | ArchivedPlaceholder = namedtuple("ArchivedPlaceholder", ["slot", "plugins"]) 19 | 20 | 21 | class ArchivedPlugin(BaseArchivedPlugin): 22 | @cached_property 23 | def model(self): 24 | return get_plugin_model(self.plugin_type) 25 | 26 | @cached_property 27 | def deserialized_instance(self): 28 | data = { 29 | "model": force_str(self.model._meta), 30 | "fields": self.data, 31 | } 32 | 33 | # TODO: Handle deserialization error 34 | return list(deserialize(get_serializer_name(), [data]))[0] 35 | 36 | @transaction.atomic 37 | def restore(self, placeholder, language, parent=None): 38 | m2m_data = {} 39 | data = self.data.copy() 40 | 41 | if self.model is not CMSPlugin: 42 | fields = self.model._meta.get_fields() 43 | for field in fields: 44 | if field.related_model is not None: 45 | if field.many_to_many: 46 | if data.get(field.name): 47 | m2m_data[field.name] = data[field.name] 48 | data.pop(field.name, None) 49 | elif data.get(field.name): 50 | try: 51 | obj = field.related_model.objects.get(pk=data[field.name]) 52 | except field.related_model.DoesNotExist: 53 | obj = None 54 | data[field.name] = obj 55 | 56 | # The "data" field for LinkPlugin have key, "target" 57 | # which clashes with :func:add_plugin when unpacked. 58 | plugin_target = data.pop("target", "") 59 | 60 | plugin = add_plugin( 61 | placeholder, 62 | self.plugin_type, 63 | language, 64 | position="last-child", 65 | target=parent, 66 | **data, 67 | ) 68 | 69 | field = ("target",) if plugin_target else () 70 | # An empty *update_fields* iterable will skip the save 71 | plugin.target = plugin_target 72 | plugin.save(update_fields=field) 73 | 74 | if self.model is not CMSPlugin: 75 | fields = self.model._meta.get_fields() 76 | for field in fields: 77 | if field.related_model is not None and m2m_data.get(field.name): 78 | if field.many_to_many: 79 | objs = field.related_model.objects.filter( 80 | pk__in=m2m_data[field.name] 81 | ) 82 | attr = getattr(plugin, field.name) 83 | attr.set(objs) 84 | 85 | # customize plugin-data on import with configured function 86 | custom_process_hook( 87 | "DJANGOCMS_TRANSFER_PROCESS_IMPORT_PLUGIN_DATA", 88 | plugin, self.data 89 | ) 90 | return plugin 91 | -------------------------------------------------------------------------------- /tests/test_toolbar.py: -------------------------------------------------------------------------------- 1 | from cms.toolbar_pool import toolbar_pool 2 | 3 | from .abstract import FunctionalityBaseTestCase 4 | 5 | 6 | class PluginImporterToolbarTestCase(FunctionalityBaseTestCase): 7 | def setUp(self): 8 | super().setUp() 9 | self.toolbars = toolbar_pool.get_toolbars() 10 | self.plugin_toolbar = next( 11 | (toolbar for toolbar in self.toolbars if toolbar.split(".")[-1] == "PluginImporter"), 12 | None 13 | ) 14 | 15 | def tearDown(self): 16 | toolbar_pool.clear() 17 | [toolbar_pool.register(toolbar) for _, toolbar in self.toolbars.items()] 18 | 19 | def _only_plugin_importer_toolbar(self): 20 | toolbar_pool.clear() 21 | toolbar_pool.register(self.toolbars[self.plugin_toolbar]) 22 | 23 | def test_toolbar_populate_no_page(self): 24 | self._only_plugin_importer_toolbar() 25 | with self.login_user_context(self.get_staff_user_with_std_permissions()): 26 | response = self.client.get("/en/admin/cms/pagecontent/") 27 | self.assertEqual(response.status_code, 200) 28 | page_menu = response.wsgi_request.toolbar.get_menu("page") 29 | self.assertEqual(page_menu, None) 30 | 31 | def test_toolbar_populate_user_cannot_change_page(self): 32 | self._only_plugin_importer_toolbar() 33 | with self.login_user_context(self.get_staff_user_with_no_permissions()): 34 | response = self.client.get(self.get_pages_root()) 35 | self.assertEqual(response.status_code, 200) 36 | page_menu = response.wsgi_request.toolbar.get_menu("page") 37 | self.assertEqual(page_menu, None) 38 | 39 | def test_toolbar_populate_page_menu_doesnot_exist(self): 40 | self._only_plugin_importer_toolbar() 41 | with self.login_user_context(self.get_staff_user_with_std_permissions()): 42 | response = self.client.get(self.get_pages_root()) 43 | self.assertEqual(response.status_code, 200) 44 | page_menu = response.wsgi_request.toolbar.get_menu("page") 45 | self.assertEqual(page_menu, None) 46 | 47 | def test_toolbar_populate_no_obj_in_toolbar(self): 48 | self._only_plugin_importer_toolbar() 49 | with self.login_user_context(self.get_staff_user_with_std_permissions()): 50 | response = self.client.get(self.get_pages_root()) 51 | self.assertEqual(response.status_code, 200) 52 | response.wsgi_request.toolbar.obj = None 53 | page_menu = response.wsgi_request.toolbar.get_menu("page") 54 | self.assertEqual(page_menu, None) 55 | 56 | def test_toolbar_populate_no_pagecontent_obj(self): 57 | with self.login_user_context(self.get_staff_user_with_std_permissions()): 58 | response = self.client.get(self.get_pages_root()) 59 | self.assertEqual(response.status_code, 200) 60 | response.wsgi_request.toolbar.obj = "not page content" 61 | page_menu = response.wsgi_request.toolbar.get_menu("page") 62 | self.assertFalse([ 63 | item for item in page_menu.items 64 | if getattr(item, "identifier", "") == "Page menu importer break" 65 | ]) 66 | 67 | def test_toolbar_populate_pagecontent_obj(self): 68 | with self.login_user_context(self.get_staff_user_with_std_permissions()): 69 | response = self.client.get(self.get_pages_root()) 70 | self.assertEqual(response.status_code, 200) 71 | page_menu = response.wsgi_request.toolbar.get_menu("page") 72 | self.assertTrue([ 73 | item for item in page_menu.items 74 | if getattr(item, "identifier", "") == "Page menu importer break" 75 | ]) 76 | self.assertTrue([ 77 | item for item in page_menu.items 78 | if getattr(item, "name", "") == "Export" 79 | ]) 80 | self.assertTrue([ 81 | item for item in page_menu.items 82 | if getattr(item, "name", "").startswith("Import") 83 | ]) 84 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | from cms.models import CMSPlugin 5 | from djangocms_text.utils import plugin_to_tag 6 | 7 | from djangocms_transfer.datastructures import ( 8 | ArchivedPlaceholder, 9 | ArchivedPlugin, 10 | ) 11 | from djangocms_transfer.exporter import export_placeholder, export_plugin 12 | from djangocms_transfer.importer import import_plugins, import_plugins_to_page 13 | 14 | from .abstract import FunctionalityBaseTestCase 15 | from .test_app.models import Section 16 | 17 | 18 | class ImportTest(FunctionalityBaseTestCase): 19 | def test_archivedplugin_restore_for_m2m_field(self): 20 | plugin = self._create_plugin(plugin_type="ArticlePlugin", title="Test") 21 | plugin.sections.set([ 22 | Section.objects.create(name="body"), 23 | Section.objects.create(name="inner-body") 24 | ]) 25 | archived_plugin = ArchivedPlugin(**json.loads(export_plugin(plugin))[0]) 26 | 27 | placeholder = self.page_content.placeholders.create(slot="test") 28 | restored_plugin = archived_plugin.restore(placeholder, "en") 29 | self.assertEqual(restored_plugin.title, "Test") 30 | # second ArticlePluginModel instance in a new placeholder 31 | self.assertEqual(restored_plugin.position, 1) 32 | self.assertEqual( 33 | restored_plugin.sections.get(name="body"), 34 | plugin.sections.get(name="body") 35 | ) 36 | 37 | def test_archivedplugin_restore_for_existing_related_field(self): 38 | placeholder = self.page_content.placeholders.create(slot="test") 39 | article_data = {"title": "Test", 40 | "section": Section.objects.create(name="body")} 41 | plugin = self._create_plugin(plugin_type="RandomPlugin", **article_data) 42 | plugin_data = json.loads(export_plugin(plugin))[0] 43 | archived_plugin = ArchivedPlugin(**plugin_data) 44 | 45 | restored_plugin = archived_plugin.restore(placeholder, "en") 46 | self.assertEqual(restored_plugin.title, "Test") 47 | self.assertEqual(restored_plugin.position, 1) 48 | self.assertEqual(restored_plugin.section, plugin.section) 49 | 50 | @unittest.skip("TODO: fix 'field.related_model.DoesNotExist' on ArchivedPlugin.restore") 51 | def test_archivedplugin_restore_for_non_existing_related_field(self): 52 | placeholder = self.page_content.placeholders.create(slot="test") 53 | article_data = {"title": "Test", 54 | "section": Section.objects.create(name="body")} 55 | plugin = self._create_plugin(plugin_type="RandomPlugin", **article_data) 56 | plugin_data = json.loads(export_plugin(plugin))[0] 57 | plugin_data["data"].pop("section") # remove section from the plugin's data 58 | no_section_archived_plugin = ArchivedPlugin(**plugin_data) 59 | 60 | no_section_restored_plugin = no_section_archived_plugin.restore(placeholder, "en") 61 | self.assertEqual(no_section_restored_plugin.section, None) 62 | 63 | def test_import(self): 64 | pagecontent = self.page_content 65 | placeholder = pagecontent.get_placeholders().get(slot="content") 66 | plugin = self._create_plugin() 67 | 68 | # create link plugin 69 | link_plugin = self._create_plugin(plugin_type="LinkPlugin", parent=plugin) 70 | 71 | # Add plugin to text body 72 | plugin.body = f"{plugin.body} {plugin_to_tag(link_plugin)}" 73 | plugin.save() 74 | 75 | link_plugin_data = ArchivedPlugin( 76 | **json.loads(export_plugin(link_plugin))[0] 77 | ) 78 | plugin_data = ArchivedPlugin(**json.loads(export_plugin(plugin))[0]) 79 | placeholder_data = ArchivedPlaceholder( 80 | "content", 81 | [ArchivedPlugin(**data) for data in json.loads(export_placeholder(placeholder, "en"))], 82 | ) 83 | 84 | with self.subTest("import plugins"): 85 | import_plugins([plugin_data, link_plugin_data], placeholder, "en") 86 | 87 | # test import updates child plugin 88 | new_plugin, new_link_plugin = map( 89 | lambda plugin: plugin.get_bound_plugin(), CMSPlugin.objects.filter(pk__in=[3,4]) 90 | ) 91 | self.assertEqual( 92 | new_plugin.body, 93 | f"{self.TEXT_BODY} {plugin_to_tag(new_link_plugin)}" 94 | ) 95 | 96 | with self.subTest("import placeholder"): 97 | import_plugins(placeholder_data.plugins, placeholder, "en") 98 | 99 | with self.subTest("import page"): 100 | import_plugins_to_page([placeholder_data], pagecontent, "en") 101 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | django CMS Transfer 3 | =================== 4 | 5 | |pypi| |coverage| |python| |django| |djangocms| 6 | 7 | 8 | **django CMS Transfer** is an **experimental** package that allows you to export 9 | and import plugin data from a page or a placeholder. It does not support foreign 10 | key relations and won't import/export related data, such as `media `_. 11 | 12 | .. note:: 13 | 14 | This project is endorsed by the `django CMS Association `_. 15 | That means that it is officially accepted by the dCA as being in line with our roadmap vision and development/plugin policy. 16 | Join us on `Slack `_. 17 | 18 | .. image:: preview.gif 19 | 20 | 21 | Documentation 22 | ============= 23 | 24 | The setting ``DJANGO_CMS_TRANSFER_SERIALIZER`` allows registration of a custom JSON serializer. An example use case would be subclassing Django's built-in Python serializer to base64-encode inline image data. 25 | 26 | See ``REQUIREMENTS`` in the `setup.py `_ 27 | file for additional dependencies: 28 | 29 | 30 | 31 | Installation 32 | ------------ 33 | 34 | For a manual install: 35 | 36 | * run ``pip install djangocms-transfer`` 37 | * add ``djangocms_transfer`` to your ``INSTALLED_APPS`` 38 | 39 | 40 | Version Compatibility 41 | --------------------- 42 | 43 | For django CMS 4.0 or later, you must use djangocms-transfer 2.0 or later. 44 | 45 | For django CMS 3.7 through 3.11 use versions 1.x of djangocms-transfer. 46 | 47 | 48 | How to Use 49 | ---------- 50 | 51 | To export/import a page, click on the "*Page*" menu on the toolbar 52 | and select your desired choice. 53 | 54 | To export/import a plugin, Open the "*Structure board*", click on the 55 | dropdown menu for the specific plugin and select your choice. 56 | 57 | 58 | Customization 59 | ------------- 60 | 61 | Following settings are available: 62 | 63 | * **DJANGOCMS_TRANSFER_PROCESS_EXPORT_PLUGIN_DATA**: 64 | 65 | Enables processing of plugin instances prior to serialization, e.g. 66 | ``myapp.module.function``. 67 | 68 | * **DJANGOCMS_TRANSFER_PROCESS_IMPORT_PLUGIN_DATA**: 69 | 70 | Enables processing of plugin instances prior to saving, e.g. 71 | ``myapp.module.function``. 72 | For example: set default-values for ForeignKeys (images for django_filer, ..) 73 | 74 | As an example the combination of ``_PROCESS_EXPORT_PLUGIN_DATA`` and 75 | ``_PROCESS_IMPORT_PLUGIN_DATA`` lets you export and import the data between 76 | different systems while setting the contents as you need it:: 77 | 78 | # settings.py 79 | .._PROCESS_EXPORT_PLUGIN_DATA = "myapp.some.module.export_function" 80 | .._PROCESS_IMPORT_PLUGIN_DATA = "myapp.some.module.import_function" 81 | 82 | # custom functions 83 | def export_function(plugin, plugin_data): 84 | # remove child-plugins which can't be handled 85 | if plugin.parent_id and plugin.parent.plugin_type == "SomeParentPlugin": 86 | return None 87 | # change data 88 | if plugin.plugin_type == "SomePlugin": 89 | plugin_data["data"].update({ 90 | "some_field": "TODO: change me", 91 | }) 92 | return plugin_data 93 | 94 | def import_function(plugin, plugin_data): 95 | some_related_fallback_object = MyModel.objects.first() 96 | for field in plugin._meta.fields: 97 | # example of setting a default value for a related field 98 | if isinstance(field, ForeignKey): 99 | plugin_value = getattr(plugin, field.attname) 100 | raw_value = plugin_data[field.name] 101 | if ( 102 | field.related_model == MyModel 103 | # related object is referenced, but does not exist 104 | and plugin_value is None and raw_value is not None 105 | ): 106 | setattr(plugin, field.name, some_related_fallback_object) 107 | # There is no guarantee on whether djangocms-transfer saves the 108 | # plugin. It is recommended to do this yourself in case you changed 109 | # the plugin. 110 | plugin.save() 111 | 112 | 113 | Running Tests 114 | ------------- 115 | 116 | You can run tests by executing:: 117 | 118 | python -m venv env 119 | source env/bin/activate 120 | pip install -r tests/requirements/dj51_cms41.txt 121 | coverage run -m pytest 122 | 123 | 124 | ******************************************* 125 | Contribute to this project and win rewards 126 | ******************************************* 127 | 128 | Because this is an open-source project, we welcome everyone to 129 | `get involved in the project `_ and 130 | `receive a reward `_ for their contribution. 131 | Become part of a fantastic community and help us make django CMS the best CMS in the world. 132 | 133 | We'll be delighted to receive your 134 | feedback in the form of issues and pull requests. Before submitting your 135 | pull request, please review our `contribution guidelines 136 | `_. 137 | 138 | We're grateful to all contributors who have helped create and maintain this package. 139 | Contributors are listed at the `contributors `_ 140 | section. 141 | 142 | 143 | .. |pypi| image:: https://badge.fury.io/py/djangocms-transfer.svg 144 | :target: http://badge.fury.io/py/djangocms-transfer 145 | .. |coverage| image:: https://codecov.io/gh/django-cms/djangocms-transfer/branch/master/graph/badge.svg 146 | :target: https://codecov.io/gh/django-cms/djangocms-transfer 147 | 148 | .. |python| image:: https://img.shields.io/badge/python-3.9+-blue.svg 149 | :target: https://pypi.org/project/djangocms-transfer/ 150 | .. |django| image:: https://img.shields.io/badge/django-4.2,%205.0,%205.1-blue.svg 151 | :target: https://www.djangoproject.com/ 152 | .. |djangocms| image:: https://img.shields.io/badge/django%20CMS-4-blue.svg 153 | :target: https://www.django-cms.org/ 154 | -------------------------------------------------------------------------------- /djangocms_transfer/cms_plugins.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from cms.plugin_base import CMSPluginBase, PluginMenuItem 4 | from cms.plugin_pool import plugin_pool 5 | from cms.utils import get_language_from_request 6 | from cms.utils.urlutils import admin_reverse 7 | from django.core.exceptions import PermissionDenied 8 | from django.http import HttpResponse, HttpResponseBadRequest 9 | from django.shortcuts import render 10 | from django.urls import re_path, reverse 11 | from django.utils.http import urlencode 12 | from django.utils.translation import gettext_lazy as _ 13 | 14 | from .forms import ExportImportForm, PluginExportForm, PluginImportForm 15 | 16 | try: 17 | from cms.toolbar.utils import get_plugin_tree 18 | except ImportError: 19 | # django CMS 4.1 still uses the legacy data bridge 20 | # This method serves as a compatibility shim 21 | from cms.toolbar.utils import get_plugin_tree_as_json 22 | 23 | def get_plugin_tree(request, plugins): 24 | json_str = get_plugin_tree_as_json(request, plugins) 25 | return json.loads(json_str) 26 | 27 | 28 | 29 | class PluginImporter(CMSPluginBase): 30 | system = True 31 | render_plugin = False 32 | 33 | def get_plugin_urls(self): 34 | urlpatterns = [ 35 | re_path( 36 | r"^export-plugins/$", 37 | self.export_plugins_view, 38 | name="cms_export_plugins", 39 | ), 40 | re_path( 41 | r"^import-plugins/$", 42 | self.import_plugins_view, 43 | name="cms_import_plugins", 44 | ), 45 | ] 46 | return urlpatterns 47 | 48 | @staticmethod 49 | def _get_extra_menu_items(query_params, request): 50 | data = urlencode({ 51 | "language": get_language_from_request(request), 52 | **query_params 53 | }) 54 | return [ 55 | PluginMenuItem( 56 | _("Export plugins"), 57 | admin_reverse("cms_export_plugins") + "?" + data, 58 | data={}, 59 | action="none", 60 | attributes={ 61 | "icon": "export", 62 | }, 63 | ), 64 | PluginMenuItem( 65 | _("Import plugins"), 66 | admin_reverse("cms_import_plugins") + "?" + data, 67 | data={}, 68 | action="modal", 69 | attributes={ 70 | "icon": "import", 71 | }, 72 | ), 73 | ] 74 | 75 | @classmethod 76 | def get_extra_plugin_menu_items(cls, request, plugin): 77 | if plugin.plugin_type == cls.__name__: 78 | return 79 | 80 | data = {"plugin": plugin.pk} 81 | return cls._get_extra_menu_items(data, request) 82 | 83 | @classmethod 84 | def get_extra_placeholder_menu_items(cls, request, placeholder): # noqa 85 | data = {"placeholder": placeholder.pk} 86 | return cls._get_extra_menu_items(data, request) 87 | 88 | @classmethod 89 | def import_plugins_view(cls, request): 90 | if not request.user.is_staff: 91 | raise PermissionDenied 92 | 93 | new_form = ExportImportForm(request.GET or None) 94 | 95 | if new_form.is_valid(): 96 | initial_data = new_form.cleaned_data 97 | else: 98 | initial_data = None 99 | 100 | if request.method == "GET" and not new_form.is_valid(): 101 | return HttpResponseBadRequest(_("Form received unexpected values.")) 102 | 103 | import_form = PluginImportForm( 104 | data=request.POST or None, 105 | files=request.FILES or None, 106 | initial=initial_data, 107 | ) 108 | 109 | if not import_form.is_valid(): 110 | opts = cls.model._meta 111 | context = { 112 | "form": import_form, 113 | "has_change_permission": True, 114 | "opts": opts, 115 | "root_path": reverse("admin:index"), 116 | "is_popup": True, 117 | "app_label": opts.app_label, 118 | "media": (cls().media + import_form.media), 119 | } 120 | return render(request, "djangocms_transfer/import_plugins.html", context) 121 | 122 | plugin = import_form.cleaned_data.get("plugin") 123 | language = import_form.cleaned_data["language"] 124 | 125 | if plugin: 126 | root_id = plugin.pk 127 | placeholder = plugin.placeholder 128 | else: 129 | root_id = None 130 | placeholder = import_form.cleaned_data.get("placeholder") 131 | 132 | if not placeholder: 133 | # Page placeholders/plugins import 134 | # TODO: Check permissions 135 | import_form.run_import() 136 | return HttpResponse( 137 | '
' 138 | ) 139 | 140 | tree_order = placeholder.get_plugin_tree_order(language, parent_id=root_id) 141 | # TODO: Check permissions 142 | import_form.run_import() 143 | 144 | if plugin: 145 | new_plugins = plugin.reload().get_descendants().exclude(pk__in=tree_order) 146 | return plugin.get_plugin_class_instance().render_close_frame( 147 | request, obj=new_plugins[0] 148 | ) 149 | 150 | # Placeholder plugins import 151 | new_plugins = placeholder.get_plugins(language).exclude(pk__in=tree_order) 152 | data = get_plugin_tree(request, list(new_plugins)) 153 | data["plugin_order"] = tree_order + ["__COPY__"] 154 | data["target_placeholder_id"] = placeholder.pk 155 | context = {"structure_data": json.dumps(data)} 156 | return render( 157 | request, "djangocms_transfer/placeholder_close_frame.html", context 158 | ) 159 | 160 | @classmethod 161 | def export_plugins_view(cls, request): 162 | if not request.user.is_staff: 163 | raise PermissionDenied 164 | 165 | form = PluginExportForm(request.GET or None) 166 | 167 | if not form.is_valid(): 168 | return HttpResponseBadRequest(_("Form received unexpected values.")) 169 | 170 | # TODO: Check permissions 171 | filename = form.get_filename() 172 | response = HttpResponse(form.run_export(), content_type='application/json') 173 | response['Content-Disposition'] = f'attachment; filename={filename}' 174 | return response 175 | 176 | 177 | plugin_pool.register_plugin(PluginImporter) 178 | -------------------------------------------------------------------------------- /djangocms_transfer/forms.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from cms.models import CMSPlugin, PageContent, Placeholder 4 | from django import forms 5 | from django.conf import settings 6 | from django.core.exceptions import ValidationError 7 | from django.utils.text import slugify 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from .datastructures import ArchivedPlaceholder, ArchivedPlugin 11 | from .exporter import export_page, export_placeholder, export_plugin 12 | from .importer import import_plugins, import_plugins_to_page 13 | 14 | 15 | def _object_version_data_hook(data, for_page=False): 16 | if not data: 17 | return data 18 | 19 | if "plugins" in data: 20 | return ArchivedPlaceholder( 21 | slot=data["placeholder"], 22 | plugins=data["plugins"], 23 | ) 24 | 25 | if "plugin_type" in data: 26 | return ArchivedPlugin(**data) 27 | return data 28 | 29 | 30 | def _get_parsed_data(file_obj, for_page=False): 31 | raw = file_obj.read().decode("utf-8") 32 | return json.loads(raw, object_hook=_object_version_data_hook) 33 | 34 | 35 | class ExportImportForm(forms.Form): 36 | plugin = forms.ModelChoiceField( 37 | CMSPlugin.objects.all(), 38 | required=False, 39 | widget=forms.HiddenInput(), 40 | ) 41 | placeholder = forms.ModelChoiceField( 42 | queryset=Placeholder.objects.all(), 43 | required=False, 44 | widget=forms.HiddenInput(), 45 | ) 46 | cms_pagecontent = forms.ModelChoiceField( 47 | queryset=PageContent.admin_manager.latest_content(), 48 | required=False, 49 | widget=forms.HiddenInput(), 50 | ) 51 | language = forms.ChoiceField( 52 | choices=settings.LANGUAGES, 53 | required=True, 54 | widget=forms.HiddenInput(), 55 | ) 56 | 57 | def clean(self): 58 | if self.errors: 59 | return self.cleaned_data 60 | 61 | plugin = self.cleaned_data.get("plugin") 62 | placeholder = self.cleaned_data.get("placeholder") 63 | cms_pagecontent = self.cleaned_data.get("cms_pagecontent") 64 | 65 | if not any([plugin, placeholder, cms_pagecontent]): 66 | message = _("A plugin, placeholder or page is required.") 67 | raise forms.ValidationError(message) 68 | 69 | if cms_pagecontent and (plugin or placeholder): 70 | message = _( 71 | "Plugins can be imported to pages, plugins or placeholders. Not all three." 72 | ) 73 | raise forms.ValidationError(message) 74 | 75 | if placeholder and (cms_pagecontent or plugin): 76 | message = _( 77 | "Plugins can be imported to pages, plugins or placeholders. Not all three." 78 | ) 79 | raise forms.ValidationError(message) 80 | 81 | if plugin and (cms_pagecontent or placeholder): 82 | message = _( 83 | "Plugins can be imported to pages, plugins or placeholders. Not all three." 84 | ) 85 | raise forms.ValidationError(message) 86 | 87 | plugin_is_bound = False 88 | if plugin: 89 | plugin_is_bound = plugin.get_bound_plugin() 90 | 91 | if plugin and not plugin_is_bound: 92 | raise ValidationError("Plugin is unbound.") 93 | return self.cleaned_data 94 | 95 | 96 | class PluginExportForm(ExportImportForm): 97 | def get_filename(self): 98 | data = self.cleaned_data 99 | language = data["language"] 100 | cms_pagecontent = data["cms_pagecontent"] 101 | plugin = data["plugin"] 102 | placeholder = data["placeholder"] 103 | 104 | if cms_pagecontent: 105 | return "{}.json".format(cms_pagecontent.page.get_slug(language=language)) 106 | elif placeholder and placeholder.page is not None: 107 | return "{}_{}.json".format( 108 | placeholder.page.get_slug(language=language), 109 | slugify(placeholder.slot), 110 | ) 111 | elif plugin is not None and plugin.page is not None: 112 | return "{}_{}.json".format( 113 | plugin.page.get_slug(language=language), 114 | slugify(plugin.get_short_description()), 115 | ) 116 | else: 117 | return "plugins.json" 118 | 119 | def run_export(self): 120 | data = self.cleaned_data 121 | language = data["language"] 122 | plugin = data["plugin"] 123 | placeholder = data["placeholder"] 124 | 125 | if plugin: 126 | return export_plugin(plugin.get_bound_plugin()) 127 | 128 | if placeholder: 129 | return export_placeholder(placeholder, language) 130 | return export_page(data["cms_pagecontent"], language) 131 | 132 | 133 | class PluginImportForm(ExportImportForm): 134 | import_file = forms.FileField( 135 | required=True, 136 | widget=forms.FileInput(attrs={"accept": "application/json"}), 137 | ) 138 | 139 | def clean(self): 140 | if self.errors: 141 | return self.cleaned_data 142 | 143 | import_file = self.cleaned_data["import_file"] 144 | 145 | try: 146 | data = _get_parsed_data(import_file) 147 | except (ValueError, TypeError): 148 | raise ValidationError("File is not valid") 149 | 150 | first_item = data[0] 151 | is_placeholder = isinstance(first_item, ArchivedPlaceholder) 152 | page_import = bool(self.cleaned_data["cms_pagecontent"]) 153 | plugins_import = not page_import 154 | 155 | if (is_placeholder and plugins_import) or (page_import and not is_placeholder): 156 | raise ValidationError("Incorrect json format used.") 157 | 158 | self.cleaned_data["import_data"] = data 159 | return self.cleaned_data 160 | 161 | def run_import(self): 162 | data = self.cleaned_data 163 | language = data["language"] 164 | target_page = data["cms_pagecontent"] 165 | target_plugin = data["plugin"] 166 | target_placeholder = data["placeholder"] 167 | 168 | if target_plugin: 169 | target_plugin_id = target_plugin.pk 170 | target_placeholder = target_plugin.placeholder 171 | else: 172 | target_plugin_id = None 173 | 174 | if target_page: 175 | import_plugins_to_page( 176 | placeholders=data["import_data"], 177 | pagecontent=target_page, 178 | language=language, 179 | ) 180 | else: 181 | import_plugins( 182 | plugins=data["import_data"], 183 | placeholder=target_placeholder, 184 | language=language, 185 | root_plugin_id=target_plugin_id, 186 | ) 187 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import unittest 4 | from tempfile import mkdtemp 5 | 6 | from django.core.files import File 7 | 8 | from djangocms_transfer.forms import PluginExportForm, PluginImportForm 9 | 10 | from .abstract import FunctionalityBaseTestCase 11 | 12 | 13 | class PluginExportFormTest(FunctionalityBaseTestCase): 14 | def test_get_filename(self): 15 | placeholder = self.page_content.get_placeholders().get(slot="content") 16 | plugin = self._create_plugin() 17 | 18 | data = { 19 | "plugin": plugin, 20 | "placeholder": placeholder, 21 | "cms_pagecontent": self.page_content, 22 | "language": "en", 23 | } 24 | 25 | with self.subTest("filename from page"): 26 | form = PluginExportForm(data=data) 27 | form.clean() 28 | self.assertEqual("home.json", form.get_filename()) 29 | 30 | with self.subTest("filename from placeholder"): 31 | data["cms_pagecontent"] = None 32 | form = PluginExportForm(data=data) 33 | form.clean() 34 | self.assertEqual("home_content.json", form.get_filename()) 35 | 36 | with self.subTest("filename from plugin"): 37 | data["placeholder"] = None 38 | form = PluginExportForm(data=data) 39 | form.clean() 40 | self.assertEqual("home_hello-world.json", form.get_filename()) 41 | 42 | with self.subTest("filename from fallback"): 43 | data["plugin"] = None 44 | form = PluginExportForm(data=data) 45 | form.clean() 46 | self.assertEqual("plugins.json", form.get_filename()) 47 | 48 | def test_validation(self): 49 | placeholder = self.page_content.get_placeholders().get(slot="content") 50 | plugin = self._create_plugin() 51 | 52 | with self.subTest("language missing"): 53 | form = PluginExportForm(data={}) 54 | self.assertEqual(["This field is required."], form.errors["language"]) 55 | 56 | with self.subTest("one of plugin/placeholder/page required"): 57 | form = PluginExportForm(data={"language": "en"}) 58 | self.assertEqual(["A plugin, placeholder or page is required."], form.errors["__all__"]) 59 | 60 | with self.subTest("cms_pagecontent + plugin given"): 61 | form = PluginExportForm(data={"language": "en", "cms_pagecontent": self.page_content, "plugin": plugin}) 62 | self.assertEqual( 63 | ["Plugins can be imported to pages, plugins or placeholders. Not all three."], 64 | form.errors["__all__"], 65 | ) 66 | 67 | with self.subTest("cms_pagecontent + placeholder given"): 68 | form = PluginExportForm( 69 | data={"language": "en", "cms_pagecontent": self.page_content, "placeholder": placeholder} 70 | ) 71 | self.assertEqual( 72 | ["Plugins can be imported to pages, plugins or placeholders. Not all three."], 73 | form.errors["__all__"], 74 | ) 75 | 76 | with self.subTest("plugin + placeholder given"): 77 | form = PluginExportForm(data={"language": "en", "plugin": plugin, "placeholder": placeholder}) 78 | self.assertEqual( 79 | ["Plugins can be imported to pages, plugins or placeholders. Not all three."], 80 | form.errors["__all__"], 81 | ) 82 | 83 | def test_run_export(self): 84 | placeholder = self.page_content.get_placeholders().get(slot="content") 85 | plugin = self._create_plugin() 86 | 87 | data = { 88 | "plugin": plugin, 89 | "placeholder": None, 90 | "cms_page": None, 91 | "language": "en", 92 | } 93 | 94 | with self.subTest("export plugin"): 95 | form = PluginExportForm(data=data) 96 | form.clean() 97 | actual = json.loads(form.run_export()) 98 | self.assertEqual(self._get_expected_plugin_export_data(), actual) 99 | 100 | with self.subTest("export placeholder"): 101 | data["placeholder"] = placeholder 102 | data["plugin"] = None 103 | form = PluginExportForm(data=data) 104 | form.clean() 105 | actual = json.loads(form.run_export()) 106 | self.assertEqual(self._get_expected_placeholder_export_data(), actual) 107 | 108 | with self.subTest("export page"): 109 | data["cms_pagecontent"] = self.page_content 110 | data["placeholder"] = None 111 | form = PluginExportForm(data=data) 112 | form.clean() 113 | actual = json.loads(form.run_export()) 114 | self.assertEqual(self._get_expected_page_export_data(), actual) 115 | 116 | 117 | class PluginImportFormTest(FunctionalityBaseTestCase): 118 | def test_validation(self): 119 | page = self.page 120 | placeholder = self.page_content.get_placeholders().get(slot="content") 121 | plugin = self._create_plugin() 122 | file_ = self._get_file() 123 | 124 | with self.subTest("file missing"): 125 | form = PluginImportForm(data={}) 126 | self.assertEqual(["This field is required."], form.errors["import_file"]) 127 | 128 | with self.subTest("language missing"): 129 | form = PluginImportForm(data={"import_file": file_}) 130 | self.assertEqual(["This field is required."], form.errors["language"]) 131 | 132 | # TODO: when setting the form, the `form.errors` is filled for "missing 133 | # import_file" although it is given/set in `data` 134 | self.skipTest("TODO: fix validation with 'import_file'") 135 | 136 | with self.subTest("one of plugin/placeholder/page required"): 137 | form = PluginImportForm(data={"language": "en", "import_file": file_}) 138 | self.assertEqual(["A plugin, placeholder or page is required."], form.errors["__all__"]) 139 | 140 | with self.subTest("cms_page + plugin given"): 141 | form = PluginImportForm(data={"import_file": file_, "language": "en", "cms_page": page, "plugin": plugin}) 142 | self.assertEqual( 143 | ["Plugins can be imported to pages, plugins or placeholders. Not all three."], 144 | form.errors["__all__"], 145 | ) 146 | 147 | with self.subTest("cms_page + placeholder given"): 148 | form = PluginImportForm( 149 | data={"import_file": file_, "language": "en", "cms_page": page, "placeholder": placeholder}, 150 | ) 151 | self.assertEqual( 152 | ["Plugins can be imported to pages, plugins or placeholders. Not all three."], 153 | form.errors["__all__"], 154 | ) 155 | 156 | with self.subTest("plugin + placeholder given"): 157 | form = PluginImportForm( 158 | data={"import_file": file_, "language": "en", "plugin": plugin, "placeholder": placeholder}, 159 | ) 160 | self.assertEqual( 161 | ["Plugins can be imported to pages, plugins or placeholders. Not all three."], 162 | form.errors["__all__"], 163 | ) 164 | 165 | @unittest.skip("TODO: fix validation with 'import_file'") 166 | def test_run_import(self): 167 | # TODO: when setting the form, the `form.errors` is filled for "missing 168 | # import_file" although it is given/set in `data` 169 | page = self.page 170 | placeholder = self.page_content.get_placeholders().get(slot="content") 171 | plugin = self._create_plugin() 172 | file_ = self._get_file() 173 | 174 | data = { 175 | "plugin": plugin, 176 | "placeholder": None, 177 | "cms_page": None, 178 | "language": "en", 179 | "import_file": file_, 180 | } 181 | 182 | with self.subTest("import plugin"): 183 | form = PluginImportForm(data=data) 184 | form.clean() 185 | form.run_import() 186 | 187 | with self.subTest("import placeholder"): 188 | data["placeholder"] = placeholder 189 | data["plugin"] = None 190 | form = PluginImportForm(data=data) 191 | form.clean() 192 | form.run_import() 193 | 194 | with self.subTest("import page"): 195 | data["cms_page"] = page 196 | data["placeholder"] = None 197 | form = PluginImportForm(data=data) 198 | form.clean() 199 | form.run_import() 200 | 201 | def _get_file(self): 202 | content = json.dumps(self._get_expected_plugin_export_data()) 203 | 204 | tmp_dir = mkdtemp() 205 | filename = os.path.join(tmp_dir, "dummy-test.json") 206 | with open(filename, "w") as f: 207 | f.write(content) 208 | 209 | return File(open(filename, "rb"), name=filename) 210 | -------------------------------------------------------------------------------- /tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from cms.plugin_pool import plugin_pool 4 | from cms.utils.urlutils import admin_reverse 5 | from django.core.exceptions import PermissionDenied 6 | from django.core.files.uploadedfile import SimpleUploadedFile 7 | 8 | from .abstract import FunctionalityBaseTestCase 9 | 10 | 11 | class PluginImporterTestCase(FunctionalityBaseTestCase): 12 | def setUp(self): 13 | super().setUp() 14 | self.user = self._create_user("test", True, True) 15 | self.plugin_importer = next( 16 | ( 17 | plugin 18 | for plugin in plugin_pool.get_all_plugins() 19 | if plugin.__name__ == "PluginImporter" 20 | ), 21 | None 22 | ) 23 | 24 | def test_get_plugin_urls(self): 25 | urlpatterns = self.plugin_importer().get_plugin_urls() 26 | self.assertEqual(len(urlpatterns), 2) 27 | self.assertEqual(urlpatterns[0].name, "cms_export_plugins") 28 | self.assertEqual(urlpatterns[1].name, "cms_import_plugins") 29 | 30 | def test_get_extra_menu_items(self): 31 | request = self.get_request() 32 | 33 | with self.subTest("extra plugin menu items"): 34 | text_plugin = self._create_plugin() 35 | pluginimporter_plugin = self._add_plugin_to_page("PluginImporter") 36 | menu_items = self.plugin_importer.get_extra_plugin_menu_items(request, text_plugin) 37 | self.assertEqual(len(menu_items), 2) 38 | self.assertEqual(menu_items[0].name, "Export plugins") 39 | self.assertEqual(menu_items[1].name, "Import plugins") 40 | self.assertEqual( 41 | menu_items[0].url, 42 | "/en/admin/cms/page/plugin/plugin_importer/export-plugins/?language=en&plugin=1" 43 | ) 44 | self.assertEqual( 45 | menu_items[1].url, 46 | "/en/admin/cms/page/plugin/plugin_importer/import-plugins/?language=en&plugin=1" 47 | ) 48 | # no menu item for PluginImporter itself 49 | self.assertIsNone(self.plugin_importer.get_extra_plugin_menu_items(request, pluginimporter_plugin)) 50 | 51 | with self.subTest("extra placeholder menu items"): 52 | placeholder = self.page_content.get_placeholders().get(slot="content") 53 | menu_items = self.plugin_importer.get_extra_placeholder_menu_items(request, placeholder) 54 | self.assertEqual( 55 | menu_items[0].url, 56 | "/en/admin/cms/page/plugin/plugin_importer/export-plugins/?language=en&placeholder=1" 57 | ) 58 | self.assertEqual( 59 | menu_items[1].url, 60 | "/en/admin/cms/page/plugin/plugin_importer/import-plugins/?language=en&placeholder=1" 61 | ) 62 | 63 | def test_import_plugin_views(self): 64 | with self.login_user_context(self.user): 65 | response = self.client.get(admin_reverse("cms_import_plugins")) 66 | self.assertEqual(response.content, b"Form received unexpected values.") 67 | 68 | del self.user # self.get_request() checks for "user" attribute 69 | self.assertRaises( 70 | PermissionDenied, 71 | self.plugin_importer.import_plugins_view, 72 | self.get_request() 73 | ) 74 | 75 | def test_import_plugin_views_for_page(self): 76 | with self.login_user_context(self.user): 77 | # GET page import 78 | response = self.client.get( 79 | admin_reverse("cms_import_plugins") + f"?language=en&cms_pagecontent={self.page_content.id}" 80 | ) 81 | self.assertEqual(response.templates[0].name, "djangocms_transfer/import_plugins.html") 82 | self.assertEqual(response.context["form"].initial["cms_pagecontent"], self.page_content) 83 | 84 | # POST page import 85 | post_data = { 86 | "language": "en", "cms_pagecontent": [self.page_content.id], 87 | "import_file": SimpleUploadedFile("file.txt", bytes(json.dumps(self._get_expected_page_export_data()), "utf-8")) 88 | } 89 | response = self.client.post( 90 | admin_reverse("cms_import_plugins") + f"?language=en&cms_pagecontent={self.page_content.id}", 91 | post_data 92 | ) 93 | self.assertIn(b'
', response.content) 94 | 95 | def test_import_plugin_views_for_placeholder(self): 96 | placeholder = self.page_content.get_placeholders().get(slot="content") 97 | with self.login_user_context(self.user): 98 | # GET placeholder import 99 | response = self.client.get( 100 | admin_reverse("cms_import_plugins") + f"?language=en&placeholder={placeholder.id}" 101 | ) 102 | self.assertEqual(response.templates[0].name, "djangocms_transfer/import_plugins.html") 103 | self.assertEqual(response.context["form"].initial["placeholder"], placeholder) 104 | 105 | # empty placeholder import (no existing plugin) 106 | request_path = admin_reverse("cms_import_plugins") + f"?language=en&placeholder={placeholder.id}" 107 | post_data = { 108 | "language": "en", "placeholder": [placeholder.id], 109 | "import_file": SimpleUploadedFile("file.txt", b"[null, null]") 110 | } 111 | with self.assertRaises(IndexError): 112 | self.client.post(path=request_path, data=post_data) 113 | 114 | # create plugins in the placeholder 115 | text_plugin = self._create_plugin() 116 | self._add_plugin_to_page("PluginImporter") 117 | self._create_plugin(plugin_type="LinkPlugin", parent=text_plugin) 118 | 119 | # empty placeholder import 120 | request_path = admin_reverse("cms_import_plugins") + f"?language=en&placeholder={placeholder.id}" 121 | post_data = { 122 | "language": "en", "placeholder": [placeholder.id], 123 | "import_file": SimpleUploadedFile("file.txt", b"[null, null]") 124 | } 125 | response = self.client.post(path=request_path, data=post_data) 126 | self.assertIn(b'
', response.content) 127 | self.assertEqual(response.context[2].template_name, "djangocms_transfer/placeholder_close_frame.html") 128 | 129 | structure_data = json.loads(response.context["structure_data"]) 130 | # Since the import file is empty, only child plugins gets 131 | # passed to the frontend data bridge 132 | self.assertEqual(len(structure_data["plugins"]), 1) 133 | 134 | # POST placeholder import: simple data 135 | post_data["import_file"] = SimpleUploadedFile( 136 | "file.txt", 137 | bytes(json.dumps(self._get_expected_placeholder_export_data()), "utf-8") 138 | ) 139 | response = self.client.post(path=request_path, data=post_data) 140 | self.assertIn(b'
', response.content) 141 | 142 | structure_data = json.loads(response.context["structure_data"]) 143 | # newly imported plugin and child plugin gets passed to the frontend data bridge 144 | self.assertEqual(len(structure_data["plugins"]), 2) 145 | 146 | def test_import_plugin_views_for_plugin(self): 147 | # create plugins 148 | text_plugin = self._create_plugin() 149 | pluginimporter_plugin = self._create_plugin("PluginImporter") 150 | self._create_plugin(plugin_type="LinkPlugin", parent=text_plugin) 151 | 152 | with self.login_user_context(self.user): 153 | # GET plugin import 154 | response = self.client.get( 155 | admin_reverse("cms_import_plugins") + f"?language=en&plugin={text_plugin.pk}" 156 | ) 157 | self.assertEqual(response.templates[0].name, "djangocms_transfer/import_plugins.html") 158 | self.assertEqual(response.context["form"].initial["plugin"], text_plugin.cmsplugin_ptr) 159 | 160 | # empty POST plugin import 161 | request_path = admin_reverse("cms_import_plugins") + f"?language=en&plugin={text_plugin.pk}" 162 | post_data = { 163 | "language": "en", "plugin": [text_plugin.id], 164 | "import_file": SimpleUploadedFile("file.txt", b"[null, null]") 165 | } 166 | with self.assertRaises(IndexError): 167 | self.client.post(path=request_path, data=post_data) 168 | 169 | # plugin import on TextPlugin 170 | post_data["import_file"] = SimpleUploadedFile( 171 | "file.txt", 172 | bytes(json.dumps(self._get_expected_placeholder_export_data()), "utf-8") 173 | ) 174 | response = self.client.post(path=request_path, data=post_data) 175 | self.assertIn(b'
', response.content) 176 | 177 | # plugin import on PluginImporterPlugin 178 | request_path = admin_reverse("cms_import_plugins") + f"?language=en&plugin={pluginimporter_plugin.pk}" 179 | post_data = {"language": "en", "plugin": [pluginimporter_plugin.id]} 180 | post_data["import_file"] = SimpleUploadedFile( 181 | "file.txt", 182 | bytes(json.dumps(self._get_expected_placeholder_export_data()), "utf-8") 183 | ) 184 | response = self.client.post(path=request_path, data=post_data) 185 | self.assertIn(b'
', response.content) 186 | 187 | def test_export_plugin_views(self): 188 | with self.login_user_context(self.user): 189 | response = self.client.get(admin_reverse("cms_export_plugins")) 190 | self.assertEqual(response.content, b"Form received unexpected values.") 191 | 192 | del self.user # self.get_request() checks for "user" attribute 193 | self.assertRaises( 194 | PermissionDenied, 195 | self.plugin_importer.export_plugins_view, 196 | self.get_request() 197 | ) 198 | 199 | def test_export_plugin_views_for_plugin(self): 200 | # create plugins 201 | text_plugin = self._create_plugin() 202 | self._create_plugin(plugin_type="LinkPlugin", parent=text_plugin) 203 | 204 | with self.login_user_context(self.user): 205 | response = self.client.get( 206 | admin_reverse("cms_export_plugins") + f"?language=en&plugin={text_plugin.pk}" 207 | ) 208 | self.assertEqual(response.status_code, 200) 209 | exported_data = json.loads(response.content) 210 | # exported content should be TextPlugin and its child (LinkPlugin). 211 | self.assertEqual(len(exported_data), 2) 212 | self.assertEqual(list(map(lambda i: i["plugin_type"], exported_data)), ["TextPlugin", "LinkPlugin"]) 213 | 214 | def test_export_plugin_views_for_placeholder(self): 215 | placeholder = self.page_content.get_placeholders().get(slot="content") 216 | # create plugins in the placeholder 217 | text_plugin = self._create_plugin() 218 | self._add_plugin_to_page("PluginImporter") 219 | self._create_plugin(plugin_type="LinkPlugin", parent=text_plugin) 220 | 221 | with self.login_user_context(self.user): 222 | response = self.client.get( 223 | admin_reverse("cms_export_plugins") + f"?language=en&placeholder={placeholder.pk}" 224 | ) 225 | self.assertEqual(response.status_code, 200) 226 | exported_data = json.loads(response.content) 227 | self.assertEqual(len(exported_data), 3) 228 | self.assertEqual(list(map(lambda i: i["plugin_type"], exported_data)), ["TextPlugin", "LinkPlugin", "PluginImporter"]) 229 | 230 | def test_export_plugin_views_for_page(self): 231 | with self.login_user_context(self.user): 232 | response = self.client.get( 233 | admin_reverse("cms_export_plugins") + f"?language=en&cms_pagecontent={self.page_content.pk}" 234 | ) 235 | self.assertEqual(response.status_code, 200) 236 | exported_data = json.loads(response.content) 237 | self.assertEqual(exported_data[0]["placeholder"], "content") 238 | self.assertEqual(len(exported_data[0]["plugins"]), 0) 239 | --------------------------------------------------------------------------------