├── 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 |
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 |
--------------------------------------------------------------------------------