├── .coveragerc ├── .editorconfig ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── lint.yml │ ├── publish-to-live-pypi.yml │ ├── publish-to-test-pypi.yml │ └── test.yml ├── .gitignore ├── .tx └── config ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── addon.json ├── djangocms_history ├── __init__.py ├── action_handlers.py ├── actions.py ├── admin.py ├── cms_plugins.py ├── cms_toolbars.py ├── compat.py ├── datastructures.py ├── forms.py ├── helpers.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 ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20170606_1001.py │ └── __init__.py ├── models.py ├── operation_handlers.py ├── signals.py ├── static │ └── djangocms_history │ │ └── color_mode.css ├── templates │ └── djangocms_history │ │ └── toolbar │ │ └── ajax_button.html ├── utils.py └── views.py ├── preview.png ├── setup.py ├── tests ├── __init__.py ├── requirements │ ├── base.txt │ ├── dj22_cms37.txt │ ├── dj22_cms38.txt │ ├── dj30_cms37.txt │ ├── dj30_cms38.txt │ └── dj31_cms38.txt ├── settings.py └── test_migrations.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = djangocms_history 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 | -------------------------------------------------------------------------------- /.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/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 | [Slack](https://www.django-cms.org/slack) to find a “pr review buddy” who is going to review my pull request. 31 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | flake8: 7 | name: flake8 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | - name: Set up Python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: 3.9 16 | - name: Install flake8 17 | run: pip install --upgrade flake8 18 | - name: Run flake8 19 | uses: liskin/gh-problem-matcher-wrap@v1 20 | with: 21 | linters: flake8 22 | run: flake8 23 | 24 | isort: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v2 29 | - name: Set up Python 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: 3.9 33 | - run: python -m pip install isort 34 | - name: isort 35 | uses: liskin/gh-problem-matcher-wrap@v1 36 | with: 37 | linters: isort 38 | run: isort -c -rc -df djangocms_history -------------------------------------------------------------------------------- /.github/workflows/publish-to-live-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish 🐍 📦 to pypi 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish 📦 to pypi 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: Set up Python 3.10 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: '3.10' 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 📦 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 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-test-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish 🐍 📦 to TestPyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish 📦 to TestPyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: Set up Python 3.10 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: '3.10' 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 📦 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/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.7, 3.8, 3.9, ] # latest release minus two 12 | requirements-file: [ 13 | dj22_cms37.txt, 14 | dj22_cms38.txt, 15 | dj30_cms37.txt, 16 | dj30_cms38.txt, 17 | dj31_cms38.txt, 18 | ] 19 | os: [ 20 | ubuntu-20.04, 21 | ] 22 | 23 | steps: 24 | - uses: actions/checkout@v1 25 | - name: Set up Python ${{ matrix.python-version }} 26 | 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install -r tests/requirements/${{ matrix.requirements-file }} 34 | python setup.py install 35 | - name: Run coverage 36 | run: coverage run setup.py test 37 | 38 | - name: Upload Coverage to Codecov 39 | uses: codecov/codecov-action@v1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *$py.class 3 | *.egg-info 4 | *.log 5 | *.pot 6 | .DS_Store 7 | .coverage/ 8 | .eggs/ 9 | .idea/ 10 | .project/ 11 | .pydevproject/ 12 | .vscode/ 13 | .settings/ 14 | .tox/ 15 | __pycache__/ 16 | build/ 17 | dist/ 18 | env/ 19 | 20 | /~ 21 | /node_modules 22 | .sass-cache 23 | *.css.map 24 | npm-debug.log 25 | package-lock.json 26 | 27 | local.sqlite 28 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [djangocms-history.djangocms_history] 5 | file_filter = djangocms_history/locale//LC_MESSAGES/django.po 6 | source_file = djangocms_history/locale/en/LC_MESSAGES/django.po 7 | source_lang = en 8 | type = PO 9 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 2.2.3 (2023-09-08) 6 | ================== 7 | 8 | * Fix: allow undo/redo for plugins that use django-entangled reference conventions 9 | * Fix: Some browsers showed a "borken image" in the undo/redo buttons (svandeneertwegh) 10 | 11 | 2.2.2 (2023-08-31) 12 | ================= 13 | 14 | * Support django dark mode starting with django CMS 3.11.4 (svandeneertwegh) 15 | * Fix: Unpin django-treebeard 16 | 17 | 2.1.0 (2022-08-19) 18 | ================== 19 | 20 | * Added support for Django 4.0 21 | 22 | 23 | 2.0.0 (2020-09-02) 24 | ================== 25 | 26 | * Added support for Django 3.1 27 | * Dropped support for Python 2.7 and Python 3.4 28 | * Dropped support for Django < 2.2 29 | 30 | 31 | 1.2.0 (2020-04-21) 32 | ================== 33 | 34 | * Added support for Django 3.0 35 | * Added support for Python 3.8 36 | 37 | 38 | 1.1.0 (2019-05-23) 39 | ================== 40 | 41 | * Added support for Django 2.2 and django CMS 3.7 42 | * Removed support for Django 2.0 43 | * Extended test matrix 44 | * Make sure placeholder operations are not shown in the admin 45 | * Added isort and adapted imports 46 | * Adapted code base to align with other supported addons 47 | * Added translations 48 | 49 | 50 | 1.0.0 (2018-12-17) 51 | ================== 52 | 53 | * Added support for Django 2.0 and 2.1 54 | * Cleaned up file structure 55 | * Added proper test setup 56 | 57 | 58 | 0.6.0 (2018-11-14) 59 | ================== 60 | 61 | * Added support for Django 1.11 62 | * Removed support for Django<1.11 63 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include djangocms_history/locale * 4 | recursive-include djangocms_history/templates * 5 | recursive-include djangocms_history/static * 6 | recursive-exclude * *.py[co] 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | django CMS History 3 | ================== 4 | 5 | |pypi| |build| |coverage| 6 | 7 | **django CMS History** is an addon application to provide undo/redo functionality in `django CMS 8 | `_, by maintaining content history. 9 | 10 | Some of the functionality in this application was previously included in django CMS itself. However, it became apparent 11 | that some users did not want it, and some wanted functionality that worked differently. 12 | 13 | In keeping with the django CMS philosophy of maintaining only core CMS functionality as part of the package itself, 14 | history management was removed from django CMS in version 3.4 and has been spun off into an independent application. 15 | 16 | django CMS History has been rewritten from the ground up. It will continue to be developed. New functionality and 17 | improvements will be introduced in future releases. 18 | 19 | 20 | .. note:: 21 | 22 | This project is considered 3rd party (no supervision by the `django CMS Association `_). Join us on `Slack `_ for more information. 23 | 24 | .. image:: preview.png 25 | 26 | ******************************************* 27 | Contribute to this project and win rewards 28 | ******************************************* 29 | 30 | Because this is a an open-source project, we welcome everyone to 31 | `get involved in the project `_ and 32 | `receive a reward `_ for their contribution. 33 | Become part of a fantastic community and help us make django CMS the best CMS in the world. 34 | 35 | We'll be delighted to receive your 36 | feedback in the form of issues and pull requests. Before submitting your 37 | pull request, please review our `contribution guidelines 38 | `_. 39 | 40 | We're grateful to all contributors who have helped create and maintain this package. 41 | Contributors are listed at the `contributors `_ 42 | section. 43 | 44 | Documentation 45 | ============= 46 | 47 | See ``REQUIREMENTS`` in the `setup.py `_ 48 | file for additional dependencies: 49 | 50 | |python| |django| |djangocms| 51 | 52 | 53 | Installation 54 | ------------ 55 | 56 | For a manual install: 57 | 58 | * run ``pip install djangocms-history`` 59 | * add ``djangocms_history`` to your ``INSTALLED_APPS`` 60 | * run ``python manage.py migrate djangocms_history`` 61 | 62 | 63 | Configuration 64 | ------------- 65 | 66 | Once installed, django CMS History will make new options available to the web content manager. These will be visible in 67 | the django CMS toolbar when managing content that is supported by the application. 68 | 69 | 70 | Running Tests 71 | ------------- 72 | 73 | You can run tests by executing:: 74 | 75 | virtualenv env 76 | source env/bin/activate 77 | pip install -r tests/requirements.txt 78 | python setup.py test 79 | 80 | 81 | .. |pypi| image:: https://badge.fury.io/py/djangocms-history.svg 82 | :target: http://badge.fury.io/py/djangocms-history 83 | .. |build| image:: https://travis-ci.org/divio/djangocms-history.svg?branch=master 84 | :target: https://travis-ci.org/divio/djangocms-history 85 | .. |coverage| image:: https://codecov.io/gh/divio/djangocms-history/branch/master/graph/badge.svg 86 | :target: https://codecov.io/gh/divio/djangocms-history 87 | 88 | .. |python| image:: https://img.shields.io/badge/python-3.5+-blue.svg 89 | :target: https://pypi.org/project/djangocms-history/ 90 | .. |django| image:: https://img.shields.io/badge/django-2.2,%203.0,%203.1-blue.svg 91 | :target: https://www.djangoproject.com/ 92 | .. |djangocms| image:: https://img.shields.io/badge/django%20CMS-3.7%2B-blue.svg 93 | :target: https://www.django-cms.org/ 94 | -------------------------------------------------------------------------------- /addon.json: -------------------------------------------------------------------------------- 1 | { 2 | "package-name": "djangocms-history", 3 | "installed-apps": [ 4 | "djangocms_history" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /djangocms_history/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.2.3' 2 | -------------------------------------------------------------------------------- /djangocms_history/action_handlers.py: -------------------------------------------------------------------------------- 1 | from cms.models import CMSPlugin 2 | from cms.utils.plugins import reorder_plugins 3 | 4 | from .helpers import delete_plugins, disable_cms_plugin_signals 5 | 6 | 7 | @disable_cms_plugin_signals 8 | def _delete_plugins(action, plugin_ids, nested=True): 9 | delete_plugins( 10 | placeholder=action.placeholder, 11 | plugin_ids=plugin_ids, 12 | nested=nested, 13 | ) 14 | action.placeholder.mark_as_dirty(action.language, clear_cache=False) 15 | 16 | 17 | def _reorder_plugins(action, parent_id=None, order=None): 18 | if not order: 19 | return 20 | 21 | reorder_plugins( 22 | action.placeholder, 23 | parent_id=parent_id, 24 | language=action.language, 25 | order=order, 26 | ) 27 | 28 | 29 | @disable_cms_plugin_signals 30 | def _restore_archived_plugins(action, data, root_plugin_id=None): 31 | plugins_by_id = {} 32 | 33 | if root_plugin_id: 34 | plugins_by_id[root_plugin_id] = CMSPlugin.objects.get(pk=root_plugin_id) 35 | 36 | for archived_plugin in data['plugins']: 37 | if archived_plugin.parent_id: 38 | parent = plugins_by_id[archived_plugin.parent_id] 39 | else: 40 | parent = None 41 | 42 | plugin = archived_plugin.restore( 43 | placeholder=action.placeholder, 44 | language=action.language, 45 | parent=parent, 46 | ) 47 | plugins_by_id[plugin.pk] = plugin 48 | 49 | action.placeholder.mark_as_dirty(action.language, clear_cache=False) 50 | 51 | 52 | @disable_cms_plugin_signals 53 | def _restore_archived_plugins_tree(action, data, root_plugin_id=None): 54 | plugin_ids = [plugin.pk for plugin in data['plugins']] 55 | plugins_by_id = CMSPlugin.objects.in_bulk(plugin_ids) 56 | plugin_data = {'language': action.language, 'placeholder': action.placeholder} 57 | 58 | if root_plugin_id: 59 | root = CMSPlugin.objects.get(pk=root_plugin_id) 60 | else: 61 | root = None 62 | 63 | for _plugin in data['plugins']: 64 | plugin = plugins_by_id[_plugin.pk] 65 | 66 | if root: 67 | plugin = plugin.update(refresh=True, parent=root, **plugin_data) 68 | plugin = plugin.move(root, pos='last-child') 69 | else: 70 | target = CMSPlugin.get_last_root_node() 71 | plugin = plugin.update(refresh=True, parent=None, **plugin_data) 72 | plugin = plugin.move(target, pos='right') 73 | 74 | # Update all children to match the parent's 75 | # language and placeholder 76 | plugin.get_descendants().update(**plugin_data) 77 | 78 | action.placeholder.mark_as_dirty(action.language, clear_cache=False) 79 | 80 | 81 | def undo_add_plugin(action): 82 | post_data = action.get_post_action_data() 83 | parent_id = post_data['parent_id'] 84 | tree_order = action.get_pre_action_data()['order'] 85 | 86 | # Only delete plugins who are direct children (or parent-less) of the 87 | # target parent. 88 | # This allows for cascade delete of the children of these plugins. 89 | plugin_ids = [plugin.pk for plugin in post_data['plugins'] 90 | if plugin.parent_id == parent_id] 91 | _delete_plugins(action, plugin_ids=plugin_ids, nested=bool(parent_id)) 92 | _reorder_plugins(action, parent_id=parent_id, order=tree_order) 93 | 94 | 95 | def redo_add_plugin(action): 96 | post_data = action.get_post_action_data() 97 | parent_id = post_data['parent_id'] 98 | _restore_archived_plugins( 99 | action, 100 | data=post_data, 101 | root_plugin_id=parent_id, 102 | ) 103 | _reorder_plugins(action, parent_id=parent_id, order=post_data['order']) 104 | 105 | 106 | def undo_change_plugin(action): 107 | archived_plugins = action.get_pre_action_data()['plugins'] 108 | 109 | for plugin in archived_plugins: 110 | if plugin.data: 111 | plugin.model.objects.filter(pk=plugin.pk).update(**plugin.data) 112 | 113 | 114 | def redo_change_plugin(action): 115 | archived_plugins = action.get_post_action_data()['plugins'] 116 | 117 | for plugin in archived_plugins: 118 | if plugin.data: 119 | plugin.model.objects.filter(pk=plugin.pk).update(**plugin.data) 120 | 121 | 122 | def undo_delete_plugin(action): 123 | pre_data = action.get_pre_action_data() 124 | parent_id = pre_data['parent_id'] 125 | _restore_archived_plugins( 126 | action, 127 | data=pre_data, 128 | root_plugin_id=parent_id, 129 | ) 130 | _reorder_plugins(action, parent_id=parent_id, order=pre_data['order']) 131 | 132 | 133 | def redo_delete_plugin(action): 134 | post_data = action.get_post_action_data() 135 | parent_id = post_data['parent_id'] 136 | plugin_ids = [plugin.pk for plugin in post_data['plugins']] 137 | _delete_plugins(action, plugin_ids=plugin_ids, nested=bool(parent_id)) 138 | _reorder_plugins(action, parent_id=parent_id, order=post_data['order']) 139 | 140 | 141 | def undo_move_plugin(action): 142 | pre_data = action.get_pre_action_data() 143 | parent_id = pre_data['parent_id'] 144 | _restore_archived_plugins_tree( 145 | action, 146 | data=pre_data, 147 | root_plugin_id=parent_id, 148 | ) 149 | _reorder_plugins( 150 | action, 151 | parent_id=parent_id, 152 | order=pre_data['order'], 153 | ) 154 | 155 | 156 | def redo_move_plugin(action): 157 | post_data = action.get_post_action_data() 158 | parent_id = post_data['parent_id'] 159 | _restore_archived_plugins_tree( 160 | action, 161 | data=post_data, 162 | root_plugin_id=parent_id, 163 | ) 164 | _reorder_plugins( 165 | action, 166 | parent_id=parent_id, 167 | order=post_data['order'], 168 | ) 169 | 170 | 171 | def undo_move_in_plugin(action): 172 | pre_data = action.get_pre_action_data() 173 | _reorder_plugins( 174 | action, 175 | parent_id=pre_data['parent_id'], 176 | order=pre_data['order'], 177 | ) 178 | 179 | 180 | def redo_move_in_plugin(action): 181 | post_data = action.get_post_action_data() 182 | parent_id = post_data['parent_id'] 183 | _restore_archived_plugins_tree( 184 | action, 185 | data=post_data, 186 | root_plugin_id=parent_id, 187 | ) 188 | _reorder_plugins( 189 | action, 190 | parent_id=parent_id, 191 | order=post_data['order'], 192 | ) 193 | 194 | 195 | def undo_move_out_plugin(action): 196 | pre_data = action.get_pre_action_data() 197 | parent_id = pre_data['parent_id'] 198 | _restore_archived_plugins_tree( 199 | action, 200 | data=pre_data, 201 | root_plugin_id=parent_id, 202 | ) 203 | _reorder_plugins( 204 | action, 205 | parent_id=parent_id, 206 | order=pre_data['order'], 207 | ) 208 | 209 | 210 | def redo_move_out_plugin(action): 211 | post_data = action.get_post_action_data() 212 | _reorder_plugins( 213 | action, 214 | parent_id=post_data['parent_id'], 215 | order=post_data['order'], 216 | ) 217 | 218 | 219 | def undo_move_plugin_in_to_clipboard(action): 220 | # clear the clipboard 221 | action.placeholder.clear() 222 | 223 | 224 | def redo_move_plugin_in_to_clipboard(action): 225 | post_data = action.get_post_action_data() 226 | 227 | # clear the clipboard 228 | action.placeholder.clear() 229 | 230 | # Add the plugin back to the clipboard 231 | # by restoring the data which points it to the clipboard 232 | # placeholder. 233 | _restore_archived_plugins_tree(action, data=post_data) 234 | 235 | 236 | def undo_move_plugin_out_to_clipboard(action): 237 | pre_data = action.get_pre_action_data() 238 | parent_id = pre_data['parent_id'] 239 | 240 | # Plugin was moved to the clipboard 241 | # Add it back to the source placeholder 242 | _restore_archived_plugins( 243 | action, 244 | data=pre_data, 245 | root_plugin_id=parent_id, 246 | ) 247 | _reorder_plugins( 248 | action, 249 | parent_id=parent_id, 250 | order=pre_data['order'], 251 | ) 252 | 253 | 254 | def redo_move_plugin_out_to_clipboard(action): 255 | post_data = action.get_post_action_data() 256 | parent_id = post_data['parent_id'] 257 | _reorder_plugins( 258 | action, 259 | parent_id=parent_id, 260 | order=post_data['order'], 261 | ) 262 | 263 | 264 | def undo_paste_plugin(action): 265 | tree_order = action.get_pre_action_data()['order'] 266 | post_data = action.get_post_action_data() 267 | parent_id = post_data['parent_id'] 268 | plugin_ids = (plugin.pk for plugin in post_data['plugins']) 269 | _delete_plugins(action, plugin_ids=plugin_ids, nested=bool(parent_id)) 270 | _reorder_plugins(action, parent_id=parent_id, order=tree_order) 271 | 272 | 273 | def redo_paste_plugin(action): 274 | post_data = action.get_post_action_data() 275 | parent_id = post_data['parent_id'] 276 | _restore_archived_plugins( 277 | action, 278 | data=post_data, 279 | root_plugin_id=parent_id, 280 | ) 281 | _reorder_plugins( 282 | action, 283 | parent_id=parent_id, 284 | order=post_data['order'], 285 | ) 286 | 287 | 288 | def undo_paste_placeholder(action): 289 | tree_order = action.get_pre_action_data()['order'] 290 | post_data = action.get_post_action_data() 291 | plugin_ids = (plugin.pk for plugin in post_data['plugins']) 292 | _delete_plugins(action, plugin_ids=plugin_ids, nested=False) 293 | _reorder_plugins(action, parent_id=None, order=tree_order) 294 | 295 | 296 | def redo_paste_placeholder(action): 297 | post_data = action.get_post_action_data() 298 | _restore_archived_plugins(action, data=post_data) 299 | _reorder_plugins(action, parent_id=None, order=post_data['order']) 300 | 301 | 302 | def undo_add_plugins_from_placeholder(action): 303 | tree_order = action.get_pre_action_data()['order'] 304 | post_data = action.get_post_action_data() 305 | plugin_ids = (plugin.pk for plugin in post_data['plugins']) 306 | _delete_plugins(action, plugin_ids=plugin_ids, nested=False) 307 | _reorder_plugins(action, parent_id=None, order=tree_order) 308 | 309 | 310 | def redo_add_plugins_from_placeholder(action): 311 | post_data = action.get_post_action_data() 312 | _restore_archived_plugins(action, data=post_data) 313 | _reorder_plugins(action, parent_id=None, order=post_data['order']) 314 | 315 | 316 | def undo_clear_placeholder(action): 317 | pre_data = action.get_pre_action_data() 318 | _restore_archived_plugins(action, data=pre_data) 319 | 320 | 321 | def redo_clear_placeholder(action): 322 | action.placeholder.clear(action.language) 323 | -------------------------------------------------------------------------------- /djangocms_history/actions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Actions are internal to djangocms-history 3 | A placeholder operation can have multiple actions. 4 | 5 | Example: 6 | User moves plugin from placeholder A to placeholder B 7 | This creates a MOVE_PLUGIN placeholder operation and this operation 8 | contains two actions: 9 | MOVE_OUT_PLUGIN -> Move plugin out of placeholder A 10 | MOVE_IN_PLUGIN -> Move plugin into placeholder B 11 | """ 12 | ADD_PLUGIN = 'add_plugin' 13 | CHANGE_PLUGIN = 'change_plugin' 14 | DELETE_PLUGIN = 'delete_plugin' 15 | MOVE_PLUGIN = 'move_plugin' 16 | MOVE_OUT_PLUGIN = 'move_out_plugin' 17 | MOVE_IN_PLUGIN = 'move_in_plugin' 18 | PASTE_PLUGIN = 'paste_plugin' 19 | PASTE_PLACEHOLDER = 'paste_placeholder' 20 | ADD_PLUGINS_FROM_PLACEHOLDER = 'add_plugins_from_placeholder' 21 | CLEAR_PLACEHOLDER = 'clear_placeholder' 22 | 23 | # This action is bound to the clipboard 24 | # Its triggered when a plugin is moved from a placeholder 25 | # into the clipboard 26 | MOVE_PLUGIN_OUT_TO_CLIPBOARD = 'move_plugin_out_to_clipboard' 27 | 28 | # This action is triggered when a plugin is moved from a placeholder 29 | # into the clipboard. Its bound to the source placeholder. 30 | MOVE_PLUGIN_IN_TO_CLIPBOARD = 'move_plugin_in_to_clipboard' 31 | -------------------------------------------------------------------------------- /djangocms_history/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import re_path 3 | 4 | from . import views 5 | from .models import PlaceholderOperation 6 | 7 | 8 | @admin.register(PlaceholderOperation) 9 | class PlaceholderOperationAdmin(admin.ModelAdmin): 10 | 11 | def get_model_perms(self, request): 12 | return {} 13 | 14 | def has_add_permission(self, request): 15 | return False 16 | 17 | def has_change_permission(self, request, obj=None): 18 | return False 19 | 20 | def has_delete_permission(self, request, obj=None): 21 | return False 22 | 23 | def get_urls(self): 24 | # This sucks but its our only way to register the internal 25 | # undo/redo urls without asking users to configure them 26 | urlpatterns = [ 27 | re_path(r'^undo/$', views.undo, name='djangocms_history_undo'), 28 | re_path(r'^redo/$', views.redo, name='djangocms_history_redo'), 29 | ] 30 | return urlpatterns 31 | -------------------------------------------------------------------------------- /djangocms_history/cms_plugins.py: -------------------------------------------------------------------------------- 1 | from cms.plugin_base import CMSPluginBase 2 | 3 | 4 | try: 5 | # Patch the plugin action_options to force the cms 6 | # to reload the page once a plugin has been moved. 7 | # This is needed to update the undo/redo buttons. 8 | # A better option is to update the buttons via js, 9 | # this however will come in a later stage. 10 | CMSPluginBase.action_options['move'] = {'requires_reload': True} 11 | except AttributeError: 12 | # django CMS 3.5 no longer supports reloading the page 13 | # on plugin actions. 14 | pass 15 | -------------------------------------------------------------------------------- /djangocms_history/cms_toolbars.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.templatetags.static import static 4 | from django.urls import reverse 5 | from django.utils.translation import gettext 6 | 7 | from cms.api import get_page_draft 8 | from cms.constants import REFRESH_PAGE 9 | from cms.toolbar.items import BaseButton, ButtonList 10 | from cms.toolbar_base import CMSToolbar 11 | from cms.toolbar_pool import toolbar_pool 12 | from cms.utils.page_permissions import user_can_change_page 13 | 14 | from sekizai.helpers import get_varname 15 | 16 | from .compat import CMS_GTE_36 17 | from .helpers import ( 18 | get_active_operation, get_inactive_operation, get_operations_from_request, 19 | ) 20 | 21 | 22 | class AjaxButton(BaseButton): 23 | template = 'djangocms_history/toolbar/ajax_button.html' 24 | 25 | def __init__(self, name, url, data, icon, active=False, disabled=False, button_type=""): 26 | self.name = name 27 | self.url = url 28 | self.active = active 29 | self.disabled = disabled 30 | self.data = data 31 | self.on_success = REFRESH_PAGE 32 | self.icon = icon 33 | self.button_type = button_type 34 | 35 | def get_context(self): 36 | return { 37 | 'name': self.name, 38 | 'icon': self.icon, 39 | 'active': self.active, 40 | 'disabled': self.disabled, 41 | 'data': json.dumps(self.data), 42 | 'url': self.url, 43 | 'on_success': self.on_success, 44 | 'button_type': self.button_type, 45 | } 46 | 47 | 48 | @toolbar_pool.register 49 | class UndoRedoToolbar(CMSToolbar): 50 | # django CMS 3.4 compatibility 51 | icon_css = '' 52 | 53 | class Media: 54 | css = { 55 | 'all': [static('djangocms_history/color_mode.css')] 56 | } 57 | 58 | @property 59 | def request_path(self): 60 | try: 61 | origin = self.toolbar.request_path 62 | except AttributeError: 63 | # django CMS < 3.5 compatibility 64 | origin = self.request.path 65 | return origin 66 | 67 | def populate(self): 68 | # django CMS >= 3.6 69 | if CMS_GTE_36 and not self.toolbar.edit_mode_active: 70 | return 71 | # django CMS <= 3.5 72 | if not CMS_GTE_36 and not self.toolbar.edit_mode: 73 | return 74 | 75 | cms_page = get_page_draft(self.request.current_page) 76 | 77 | if not cms_page or user_can_change_page(self.request.user, cms_page): 78 | self.active_operation = self.get_active_operation() 79 | self.add_buttons() 80 | 81 | def get_operations(self): 82 | if CMS_GTE_36: 83 | toolbar_language = self.toolbar.toolbar_language 84 | else: 85 | toolbar_language = self.toolbar.language 86 | 87 | operations = get_operations_from_request( 88 | self.request, 89 | path=self.request_path, 90 | language=toolbar_language, 91 | ) 92 | return operations 93 | 94 | def get_active_operation(self): 95 | operations = self.get_operations() 96 | return get_active_operation(operations) 97 | 98 | def get_inactive_operation(self): 99 | operations = self.get_operations() 100 | operation = get_inactive_operation( 101 | operations, 102 | active_operation=self.active_operation, 103 | ) 104 | return operation 105 | 106 | def add_buttons(self): 107 | container = ButtonList(side=self.toolbar.RIGHT) 108 | container.buttons.append(self.get_undo_button()) 109 | container.buttons.append(self.get_redo_button()) 110 | self.toolbar.add_item(container) 111 | 112 | def _get_ajax_button(self, name, url, icon, button_type, disabled=True): 113 | if CMS_GTE_36: 114 | toolbar_language = self.toolbar.toolbar_language 115 | else: 116 | toolbar_language = self.toolbar.language 117 | 118 | data = { 119 | 'language': toolbar_language, 120 | 'cms_path': self.request_path, 121 | 'csrfmiddlewaretoken': self.toolbar.csrf_token, 122 | } 123 | button = AjaxButton( 124 | name=name, 125 | url=url, 126 | data=data, 127 | icon='', 128 | active=False, 129 | disabled=disabled, 130 | button_type=button_type 131 | ) 132 | return button 133 | 134 | def get_undo_button(self): 135 | url = reverse('admin:djangocms_history_undo') 136 | disabled = not bool(self.active_operation) 137 | button = self._get_ajax_button( 138 | name=gettext('Undo'), 139 | url=url, 140 | icon='', 141 | disabled=disabled, 142 | button_type="undo" 143 | ) 144 | return button 145 | 146 | def get_redo_button(self): 147 | operation = self.get_inactive_operation() 148 | url = reverse('admin:djangocms_history_redo') 149 | disabled = not bool(operation) 150 | button = self._get_ajax_button( 151 | name=gettext('Redo'), 152 | url=url, 153 | icon='', 154 | disabled=disabled, 155 | button_type="redo", 156 | ) 157 | return button 158 | 159 | def render_addons(self, context): 160 | # django CMS 3.4 compatibility 161 | context[get_varname()]['css'].append(self.icon_css) 162 | return [] 163 | -------------------------------------------------------------------------------- /djangocms_history/compat.py: -------------------------------------------------------------------------------- 1 | from distutils.version import LooseVersion 2 | 3 | from cms import __version__ 4 | 5 | 6 | # Django >= 3.6 7 | CMS_GTE_36 = LooseVersion(__version__) >= LooseVersion('3.6') 8 | -------------------------------------------------------------------------------- /djangocms_history/datastructures.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from django.core.serializers import deserialize 4 | from django.db import transaction 5 | from django.utils.encoding import force_str 6 | from django.utils.functional import cached_property 7 | 8 | from cms.models import CMSPlugin 9 | 10 | from .utils import get_plugin_model 11 | 12 | 13 | BaseArchivedPlugin = namedtuple( 14 | 'BaseArchivedPlugin', 15 | ['pk', 'creation_date', 'position', 'plugin_type', 'parent_id', 'data'] 16 | ) 17 | 18 | 19 | class ArchivedPlugin(BaseArchivedPlugin): 20 | 21 | @cached_property 22 | def model(self): 23 | return get_plugin_model(self.plugin_type) 24 | 25 | @cached_property 26 | def deserialized_instance(self): 27 | data = { 28 | 'model': force_str(self.model._meta), 29 | 'fields': self.data, 30 | } 31 | 32 | # TODO: Handle deserialization error 33 | return list(deserialize('python', [data]))[0] 34 | 35 | @transaction.atomic 36 | def restore(self, placeholder, language, parent=None): 37 | plugin_kwargs = { 38 | 'pk': self.pk, 39 | 'plugin_type': self.plugin_type, 40 | 'placeholder': placeholder, 41 | 'language': language, 42 | 'parent': parent, 43 | 'position': self.position, 44 | } 45 | 46 | if parent: 47 | plugin = parent.add_child(**plugin_kwargs) 48 | else: 49 | plugin = CMSPlugin.add_root(**plugin_kwargs) 50 | 51 | if self.plugin_type != 'CMSPlugin': 52 | _d_instance = self.deserialized_instance 53 | _d_instance.object._no_reorder = True 54 | plugin.set_base_attr(_d_instance.object) 55 | _d_instance.save() 56 | return plugin 57 | -------------------------------------------------------------------------------- /djangocms_history/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | 4 | 5 | class UndoRedoForm(forms.Form): 6 | language = forms.ChoiceField( 7 | choices=settings.LANGUAGES, 8 | required=True, 9 | ) 10 | cms_path = forms.CharField(required=True) 11 | -------------------------------------------------------------------------------- /djangocms_history/helpers.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from datetime import timedelta 3 | 4 | from django.contrib.sites.models import Site 5 | from django.core import serializers 6 | from django.core.exceptions import ObjectDoesNotExist 7 | from django.db.models import signals 8 | from django.utils import timezone 9 | 10 | from cms.models import CMSPlugin 11 | from cms.utils import get_language_from_request 12 | 13 | from .compat import CMS_GTE_36 14 | from .utils import get_plugin_fields, get_plugin_model 15 | 16 | 17 | def delete_plugins(placeholder, plugin_ids, nested=True): 18 | # With plugins, we can't do queryset.delete() 19 | # because this would trigger a bunch of internal 20 | # cms signals. 21 | # Instead, delete each plugin individually and turn off 22 | # position reordering using the _no_reorder trick. 23 | plugins = ( 24 | placeholder 25 | .cmsplugin_set 26 | .filter(pk__in=plugin_ids) 27 | .order_by('-depth') 28 | .select_related() 29 | ) 30 | 31 | bound_plugins = get_bound_plugins(plugins) 32 | 33 | for plugin in bound_plugins: 34 | plugin._no_reorder = True 35 | 36 | if hasattr(plugin, 'cmsplugin_ptr'): 37 | plugin.cmsplugin_ptr._no_reorder = True 38 | 39 | # When the nested option is False 40 | # avoid queries by preventing the cms from 41 | # recalculating the child counter of this plugin's 42 | # parent (for which there's none). 43 | plugin.delete(no_mp=not nested) 44 | 45 | 46 | def get_bound_plugins(plugins): 47 | plugin_types_map = defaultdict(list) 48 | plugin_lookup = {} 49 | 50 | # make a map of plugin types, needed later for downcasting 51 | for plugin in plugins: 52 | plugin_types_map[plugin.plugin_type].append(plugin.pk) 53 | 54 | for plugin_type, pks in plugin_types_map.items(): 55 | plugin_model = get_plugin_model(plugin_type) 56 | plugin_queryset = plugin_model.objects.filter(pk__in=pks) 57 | 58 | # put them in a map so we can replace the base CMSPlugins with their 59 | # downcasted versions 60 | for instance in plugin_queryset.iterator(): 61 | plugin_lookup[instance.pk] = instance 62 | 63 | for plugin in plugins: 64 | yield plugin_lookup.get(plugin.pk, plugin) 65 | 66 | 67 | def get_plugin_data(plugin, only_meta=False): 68 | if only_meta: 69 | custom_data = None 70 | else: 71 | plugin_fields = get_plugin_fields(plugin.plugin_type) 72 | _plugin_data = serializers.serialize('python', (plugin,), fields=plugin_fields)[0] 73 | custom_data = _plugin_data['fields'] 74 | 75 | plugin_data = { 76 | 'pk': plugin.pk, 77 | 'creation_date': plugin.creation_date, 78 | 'position': plugin.position, 79 | 'plugin_type': plugin.plugin_type, 80 | 'parent_id': plugin.parent_id, 81 | 'data': custom_data, 82 | } 83 | return plugin_data 84 | 85 | 86 | def get_active_operation(operations): 87 | operations = operations.filter(is_applied=True) 88 | 89 | try: 90 | operation = operations.latest() 91 | except ObjectDoesNotExist: 92 | operation = None 93 | return operation 94 | 95 | 96 | def get_inactive_operation(operations, active_operation=None): 97 | active_operation = active_operation or get_active_operation(operations) 98 | 99 | if active_operation: 100 | date_created = active_operation.date_created 101 | operations = operations.filter(date_created__gt=date_created) 102 | 103 | try: 104 | operation = operations.filter(is_applied=False).earliest() 105 | except ObjectDoesNotExist: 106 | operation = None 107 | return operation 108 | 109 | 110 | def get_operations_from_request(request, path=None, language=None): 111 | from .models import PlaceholderOperation 112 | 113 | if not language: 114 | language = get_language_from_request(language) 115 | 116 | origin = path or request.path 117 | 118 | # This is controversial :/ 119 | # By design, we don't let undo/redo span longer than a day. 120 | # To be decided if/how this should be configurable. 121 | date = timezone.now() - timedelta(days=1) 122 | 123 | site = Site.objects.get_current(request) 124 | 125 | queryset = PlaceholderOperation.objects.filter( 126 | site=site, 127 | origin=origin, 128 | language=language, 129 | user=request.user, 130 | user_session_key=request.session.session_key, 131 | date_created__gt=date, 132 | is_archived=False, 133 | ) 134 | return queryset 135 | 136 | 137 | def disable_cms_plugin_signals(func): 138 | # Skip this if we are using django CMS >= 3.6 139 | if CMS_GTE_36: 140 | return func 141 | 142 | from cms.signals import ( 143 | post_delete_plugins, pre_delete_plugins, pre_save_plugins, 144 | ) 145 | 146 | # The wrapped function NEEDS to set _no_reorder on any bound plugin instance 147 | # otherwise this does nothing because it only disconnects signals 148 | # for the cms.CMSPlugin class, not its subclasses 149 | plugin_signals = ( 150 | (signals.pre_delete, pre_delete_plugins, 'cms_pre_delete_plugin', CMSPlugin), 151 | (signals.pre_save, pre_save_plugins, 'cms_pre_save_plugin', CMSPlugin), 152 | (signals.post_delete, post_delete_plugins, 'cms_post_delete_plugin', CMSPlugin), 153 | ) 154 | 155 | def wrapper(*args, **kwargs): 156 | for signal, handler, dispatch_id, model_class in plugin_signals: 157 | signal.disconnect( 158 | handler, 159 | sender=model_class, 160 | dispatch_uid=dispatch_id 161 | ) 162 | signal.disconnect(handler, sender=model_class) 163 | 164 | func(*args, **kwargs) 165 | 166 | for signal, handler, dispatch_id, model_class in plugin_signals: 167 | signal.connect( 168 | handler, 169 | sender=model_class, 170 | dispatch_uid=dispatch_id 171 | ) 172 | 173 | return wrapper 174 | -------------------------------------------------------------------------------- /djangocms_history/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-history/b675553820f44fe629f67487392946d305a6001b/djangocms_history/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /djangocms_history/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-16 15:38+0100\n" 15 | "PO-Revision-Date: 2019-01-16 14:39+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 | #: cms_toolbars.py:155 25 | msgid "Undo" 26 | msgstr "Rückgängig" 27 | 28 | #: cms_toolbars.py:167 29 | msgid "Redo" 30 | msgstr "Wiederherstellen" 31 | -------------------------------------------------------------------------------- /djangocms_history/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-history/b675553820f44fe629f67487392946d305a6001b/djangocms_history/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /djangocms_history/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-16 15:38+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 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Language: \n" 19 | 20 | #: cms_toolbars.py:155 21 | msgid "Undo" 22 | msgstr "Undo" 23 | 24 | #: cms_toolbars.py:167 25 | msgid "Redo" 26 | msgstr "Redo" 27 | -------------------------------------------------------------------------------- /djangocms_history/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-history/b675553820f44fe629f67487392946d305a6001b/djangocms_history/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /djangocms_history/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-16 15:38+0100\n" 12 | "PO-Revision-Date: 2019-01-16 14:39+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 | #: cms_toolbars.py:155 21 | msgid "Undo" 22 | msgstr "" 23 | 24 | #: cms_toolbars.py:167 25 | msgid "Redo" 26 | msgstr "" 27 | -------------------------------------------------------------------------------- /djangocms_history/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-history/b675553820f44fe629f67487392946d305a6001b/djangocms_history/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /djangocms_history/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-16 15:38+0100\n" 12 | "PO-Revision-Date: 2019-01-16 14:39+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 | #: cms_toolbars.py:155 21 | msgid "Undo" 22 | msgstr "" 23 | 24 | #: cms_toolbars.py:167 25 | msgid "Redo" 26 | msgstr "" 27 | -------------------------------------------------------------------------------- /djangocms_history/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('cms', '0016_auto_20160608_1535'), 9 | ('sites', '0001_initial'), 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='PlaceholderAction', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('action', models.CharField(max_length=30, choices=[('add_plugin', 'Add plugin'), ('change_plugin', 'Change plugin'), ('delete_plugin', 'Delete plugin'), ('move_plugin', 'Move plugin'), ('move_out_plugin', 'Move out plugin'), ('move_in_plugin', 'Move in plugin'), ('move_plugin_out_to_clipboard', 'Move out to clipboard'), ('move_plugin_in_to_clipboard', 'Move in to clipboard'), ('add_plugins_from_placeholder', 'Add plugins from placeholder'), ('paste_plugin', 'Paste plugin'), ('paste_placeholder', 'Paste placeholder'), ('clear_placeholder', 'Clear placeholder')])), 19 | ('pre_action_data', models.TextField(blank=True)), 20 | ('post_action_data', models.TextField(blank=True)), 21 | ('language', models.CharField(max_length=5, choices=settings.LANGUAGES)), 22 | ('order', models.PositiveIntegerField(default=1)), 23 | ], 24 | options={ 25 | 'ordering': ['order'], 26 | }, 27 | ), 28 | migrations.CreateModel( 29 | name='PlaceholderOperation', 30 | fields=[ 31 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 32 | ('operation_type', models.CharField(max_length=30, choices=[('add_plugin', 'Add plugin'), ('change_plugin', 'Change plugin'), ('delete_plugin', 'Delete plugin'), ('move_plugin', 'Move plugin'), ('cut_plugin', 'Cut plugin'), ('paste_plugin', 'Paste plugin'), ('paste_placeholder', 'Paste placeholder'), ('add_plugins_from_placeholder', 'Add plugins from placeholder'), ('clear_placeholder', 'Clear placeholder')])), 33 | ('token', models.CharField(max_length=120, db_index=True)), 34 | ('origin', models.CharField(max_length=255, db_index=True)), 35 | ('language', models.CharField(max_length=5, choices=settings.LANGUAGES)), 36 | ('user_session_key', models.CharField(max_length=120, db_index=True)), 37 | ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='date created', db_index=True)), 38 | ('is_applied', models.BooleanField(default=False)), 39 | ('is_archived', models.BooleanField(default=False)), 40 | ('site', models.ForeignKey(to='sites.Site', on_delete=models.CASCADE)), 41 | ('user', models.ForeignKey(verbose_name='user', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 42 | ], 43 | options={ 44 | 'ordering': ['-date_created'], 45 | 'get_latest_by': 'date_created', 46 | }, 47 | ), 48 | migrations.AddField( 49 | model_name='placeholderaction', 50 | name='operation', 51 | field=models.ForeignKey(related_name='actions', to='djangocms_history.PlaceholderOperation', on_delete=models.CASCADE), 52 | ), 53 | migrations.AddField( 54 | model_name='placeholderaction', 55 | name='placeholder', 56 | field=models.ForeignKey(to='cms.Placeholder', on_delete=models.CASCADE), 57 | ), 58 | migrations.AlterUniqueTogether( 59 | name='placeholderaction', 60 | unique_together=set([('operation', 'order')]), 61 | ), 62 | ] 63 | -------------------------------------------------------------------------------- /djangocms_history/migrations/0002_auto_20170606_1001.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('djangocms_history', '0001_initial'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='placeholderaction', 14 | name='language', 15 | field=models.CharField(max_length=15, choices=settings.LANGUAGES), 16 | ), 17 | migrations.AlterField( 18 | model_name='placeholderoperation', 19 | name='language', 20 | field=models.CharField(max_length=15, choices=settings.LANGUAGES), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /djangocms_history/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-history/b675553820f44fe629f67487392946d305a6001b/djangocms_history/migrations/__init__.py -------------------------------------------------------------------------------- /djangocms_history/models.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import operator 4 | 5 | from django.conf import settings 6 | from django.contrib.auth.signals import user_logged_in 7 | from django.contrib.sites.models import Site 8 | from django.core.serializers.json import DjangoJSONEncoder 9 | from django.db import models, transaction 10 | from django.db.models import Q 11 | from django.dispatch import receiver 12 | 13 | from cms import operations 14 | from cms.models import Placeholder 15 | from cms.signals import ( 16 | post_placeholder_operation, pre_obj_operation, pre_placeholder_operation, 17 | ) 18 | 19 | from . import action_handlers, actions, operation_handlers, signals 20 | from .datastructures import ArchivedPlugin 21 | 22 | 23 | dump_json = functools.partial(json.dumps, cls=DjangoJSONEncoder) 24 | 25 | 26 | # TODO: This will likely change into a class based pool integration 27 | # to allow for custom operations and actions 28 | 29 | _operation_handlers = { 30 | operations.ADD_PLUGIN: { 31 | 'pre': operation_handlers.pre_add_plugin, 32 | 'post': operation_handlers.post_add_plugin, 33 | }, 34 | operations.CHANGE_PLUGIN: { 35 | 'pre': operation_handlers.pre_change_plugin, 36 | 'post': operation_handlers.post_change_plugin, 37 | }, 38 | operations.DELETE_PLUGIN: { 39 | 'pre': operation_handlers.pre_delete_plugin, 40 | 'post': operation_handlers.post_delete_plugin, 41 | }, 42 | operations.MOVE_PLUGIN: { 43 | 'pre': operation_handlers.pre_move_plugin, 44 | 'post': operation_handlers.post_move_plugin, 45 | }, 46 | operations.CUT_PLUGIN: { 47 | 'pre': operation_handlers.pre_cut_plugin, 48 | 'post': operation_handlers.post_cut_plugin, 49 | }, 50 | operations.PASTE_PLUGIN: { 51 | 'pre': operation_handlers.pre_paste_plugin, 52 | 'post': operation_handlers.post_paste_plugin, 53 | }, 54 | operations.PASTE_PLACEHOLDER: { 55 | 'pre': operation_handlers.pre_paste_placeholder, 56 | 'post': operation_handlers.post_paste_placeholder, 57 | }, 58 | operations.ADD_PLUGINS_FROM_PLACEHOLDER: { 59 | 'pre': operation_handlers.pre_add_plugins_from_placeholder, 60 | 'post': operation_handlers.post_add_plugins_from_placeholder, 61 | }, 62 | operations.CLEAR_PLACEHOLDER: { 63 | 'pre': operation_handlers.pre_clear_placeholder, 64 | 'post': operation_handlers.post_clear_placeholder, 65 | }, 66 | } 67 | 68 | _action_handlers = { 69 | actions.ADD_PLUGIN: { 70 | 'undo': action_handlers.undo_add_plugin, 71 | 'redo': action_handlers.redo_add_plugin, 72 | }, 73 | actions.CHANGE_PLUGIN: { 74 | 'undo': action_handlers.undo_change_plugin, 75 | 'redo': action_handlers.redo_change_plugin, 76 | }, 77 | actions.DELETE_PLUGIN: { 78 | 'undo': action_handlers.undo_delete_plugin, 79 | 'redo': action_handlers.redo_delete_plugin, 80 | }, 81 | actions.MOVE_PLUGIN: { 82 | 'undo': action_handlers.undo_move_plugin, 83 | 'redo': action_handlers.redo_move_plugin, 84 | }, 85 | actions.MOVE_OUT_PLUGIN: { 86 | 'undo': action_handlers.undo_move_out_plugin, 87 | 'redo': action_handlers.redo_move_out_plugin, 88 | }, 89 | actions.MOVE_IN_PLUGIN: { 90 | 'undo': action_handlers.undo_move_in_plugin, 91 | 'redo': action_handlers.redo_move_in_plugin, 92 | }, 93 | actions.MOVE_PLUGIN_OUT_TO_CLIPBOARD: { 94 | 'undo': action_handlers.undo_move_plugin_out_to_clipboard, 95 | 'redo': action_handlers.redo_move_plugin_out_to_clipboard, 96 | }, 97 | actions.MOVE_PLUGIN_IN_TO_CLIPBOARD: { 98 | 'undo': action_handlers.undo_move_plugin_in_to_clipboard, 99 | 'redo': action_handlers.redo_move_plugin_in_to_clipboard, 100 | }, 101 | actions.PASTE_PLUGIN: { 102 | 'undo': action_handlers.undo_paste_plugin, 103 | 'redo': action_handlers.redo_paste_plugin, 104 | }, 105 | actions.PASTE_PLACEHOLDER: { 106 | 'undo': action_handlers.undo_paste_placeholder, 107 | 'redo': action_handlers.redo_paste_placeholder, 108 | }, 109 | actions.ADD_PLUGINS_FROM_PLACEHOLDER: { 110 | 'undo': action_handlers.undo_add_plugins_from_placeholder, 111 | 'redo': action_handlers.redo_add_plugins_from_placeholder, 112 | }, 113 | actions.CLEAR_PLACEHOLDER: { 114 | 'undo': action_handlers.undo_clear_placeholder, 115 | 'redo': action_handlers.redo_clear_placeholder, 116 | }, 117 | } 118 | 119 | 120 | @receiver(user_logged_in, dispatch_uid='archive_old_operations') 121 | def archive_old_operations(sender, request, user, **kwargs): 122 | """ 123 | Archives all user operations that don't match the new user session 124 | """ 125 | site = Site.objects.get_current(request) 126 | 127 | if not hasattr(request, 'user'): 128 | # On test environments, its possible the user attribute has not 129 | # been set. 130 | return 131 | 132 | p_operations = ( 133 | PlaceholderOperation 134 | .objects 135 | .filter(user=request.user, site=site) 136 | .exclude(user_session_key=request.session.session_key) 137 | ) 138 | p_operations.update(is_archived=True) 139 | 140 | 141 | @receiver(pre_obj_operation) 142 | def pre_page_operation_handler(sender, **kwargs): 143 | operation_type = kwargs['operation'] 144 | p_operations = PlaceholderOperation.objects.all() 145 | 146 | if operation_type == operations.PUBLISH_STATIC_PLACEHOLDER: 147 | # Fetch all operations which act on the published 148 | # static placeholder 149 | p_id = kwargs['obj'].draft_id 150 | p_operations = p_operations.filter(actions__placeholder=p_id) 151 | elif operation_type in operations.PAGE_TRANSLATION_OPERATIONS: 152 | # Fetch all operations which act on the translation only 153 | page = kwargs['obj'] 154 | translation = kwargs['translation'] 155 | page_urls = (page.get_absolute_url(lang) for lang in page.get_languages()) 156 | p_operations = p_operations.filter( 157 | origin__in=page_urls, 158 | language=translation.language, 159 | ) 160 | else: 161 | # Fetch all operations which act on a page including its children 162 | # for all languages of the page 163 | page = kwargs['obj'] 164 | page_urls = (page.get_absolute_url(lang) for lang in page.get_languages()) 165 | queries = [Q(origin__startswith=url) for url in page_urls] 166 | p_operations = p_operations.filter(functools.reduce(operator.or_, queries)) 167 | 168 | # Both cms.Page and cms.StaticPlaceholder have a site field 169 | # the site field on cms.StaticPlaceholder is optional though. 170 | try: 171 | site_id = kwargs['obj'].node.site_id 172 | except AttributeError: 173 | site_id = kwargs['obj'].site_id 174 | 175 | if site_id: 176 | p_operations = p_operations.filter(site=site_id) 177 | 178 | # Archive all fetched operations 179 | p_operations.update(is_archived=True) 180 | 181 | 182 | @receiver(pre_placeholder_operation) 183 | def create_placeholder_operation(sender, **kwargs): 184 | """ 185 | Creates the initial placeholder operation record 186 | """ 187 | request = kwargs.pop('request') 188 | operation_type = kwargs.pop('operation') 189 | handler = _operation_handlers.get(operation_type, {}).get('pre') 190 | 191 | # Adding cms_history=0 to any of the operation endpoints 192 | # will prevent the recording of history for that one operation 193 | cms_history = request.GET.get('cms_history', True) 194 | cms_history = models.BooleanField().to_python(cms_history) 195 | 196 | if not handler or not cms_history: 197 | return 198 | 199 | # kwargs['language'] can be None if the user has not enabled 200 | # I18N or is not using i18n_patterns 201 | language = kwargs['language'] or settings.LANGUAGE_CODE 202 | 203 | operation = PlaceholderOperation.objects.create( 204 | operation_type=operation_type, 205 | token=kwargs['token'], 206 | origin=kwargs['origin'], 207 | language=language, 208 | user=request.user, 209 | user_session_key=request.session.session_key, 210 | site=Site.objects.get_current(request), 211 | ) 212 | handler(operation, **kwargs) 213 | 214 | 215 | @receiver(post_placeholder_operation) 216 | def update_placeholder_operation(sender, **kwargs): 217 | """ 218 | Updates the created placeholder operation record, 219 | based on the configured post operation handlers. 220 | """ 221 | request = kwargs.pop('request') 222 | operation_type = kwargs.pop('operation') 223 | 224 | handler = _operation_handlers.get(operation_type, {}).get('post') 225 | 226 | # Adding cms_history=0 to any of the operation endpoints 227 | # will prevent the recording of history for that one operation 228 | cms_history = request.GET.get('cms_history', True) 229 | cms_history = models.BooleanField().to_python(cms_history) 230 | 231 | if not handler or not cms_history: 232 | return 233 | 234 | site = Site.objects.get_current(request) 235 | 236 | p_operations = PlaceholderOperation.objects.filter( 237 | site=site, 238 | user=request.user, 239 | user_session_key=request.session.session_key 240 | ) 241 | 242 | operation = p_operations.get(token=kwargs['token']) 243 | 244 | # Run the placeholder operation handler 245 | handler(operation, **kwargs) 246 | 247 | # Mark the new operation as applied 248 | p_operations.filter(pk=operation.pk).update(is_applied=True) 249 | 250 | # Mark any operation from this user's session made on a separate path 251 | # or made on the current path but not applied as archived. 252 | p_operations.filter( 253 | ~ Q(origin=kwargs['origin']) 254 | | Q(origin=kwargs['origin'], is_applied=False) 255 | ).update(is_archived=True) 256 | 257 | # Last, mark any operation made by another user on the current path 258 | # as archived. 259 | # TODO: This will need to change to allow for concurrent editing 260 | # Its actually better to get the affected placeholders 261 | # and archive any operations that contains those 262 | foreign_operations = ( 263 | PlaceholderOperation 264 | .objects 265 | .filter(origin=kwargs['origin'], site=site) 266 | .exclude(user=request.user) 267 | ) 268 | foreign_operations.update(is_archived=True) 269 | 270 | 271 | class PlaceholderOperation(models.Model): 272 | 273 | OPERATION_TYPES = ( 274 | (operations.ADD_PLUGIN, 'Add plugin'), 275 | (operations.CHANGE_PLUGIN, 'Change plugin'), 276 | (operations.DELETE_PLUGIN, 'Delete plugin'), 277 | (operations.MOVE_PLUGIN, 'Move plugin'), 278 | (operations.CUT_PLUGIN, 'Cut plugin'), 279 | (operations.PASTE_PLUGIN, 'Paste plugin'), 280 | (operations.PASTE_PLACEHOLDER, 'Paste placeholder'), 281 | (operations.ADD_PLUGINS_FROM_PLACEHOLDER, 'Add plugins from placeholder'), 282 | (operations.CLEAR_PLACEHOLDER, 'Clear placeholder'), 283 | ) 284 | 285 | operation_type = models.CharField(max_length=30, choices=OPERATION_TYPES) 286 | token = models.CharField(max_length=120, db_index=True) 287 | origin = models.CharField(max_length=255, db_index=True) 288 | language = models.CharField(max_length=15, choices=settings.LANGUAGES) 289 | user = models.ForeignKey( 290 | settings.AUTH_USER_MODEL, 291 | on_delete=models.CASCADE, 292 | verbose_name="user", 293 | ) 294 | # Django uses 40 character session keys but other backends might use longer.. 295 | user_session_key = models.CharField(max_length=120, db_index=True) 296 | date_created = models.DateTimeField( 297 | db_index=True, 298 | auto_now_add=True, 299 | verbose_name="date created", 300 | ) 301 | is_applied = models.BooleanField(default=False) 302 | is_archived = models.BooleanField(default=False) 303 | site = models.ForeignKey(Site, on_delete=models.CASCADE) 304 | 305 | class Meta: 306 | get_latest_by = "date_created" 307 | ordering = ['-date_created'] 308 | 309 | def create_action(self, action, language, placeholder, **kwargs): 310 | pre_data = kwargs.pop('pre_data', '') 311 | 312 | if pre_data: 313 | pre_data = dump_json(pre_data) 314 | 315 | post_data = kwargs.pop('post_data', '') 316 | 317 | if post_data: 318 | post_data = dump_json(post_data) 319 | 320 | self.actions.create( 321 | action=action, 322 | pre_action_data=pre_data, 323 | post_action_data=post_data, 324 | language=language, 325 | placeholder=placeholder, 326 | **kwargs 327 | ) 328 | 329 | def set_pre_action_data(self, action, data): 330 | self.actions.filter(action=action).update(pre_action_data=dump_json(data)) 331 | 332 | def set_post_action_data(self, action, data): 333 | self.actions.filter(action=action).update(post_action_data=dump_json(data)) 334 | 335 | @transaction.atomic 336 | def undo(self): 337 | actions = self.actions.order_by('order') 338 | 339 | for action in actions: 340 | action.undo() 341 | 342 | self.is_applied = False 343 | self.save(update_fields=['is_applied']) 344 | signals.post_operation_undo.send( 345 | sender=self.__class__, 346 | operation=self, 347 | actions=actions, 348 | ) 349 | 350 | @transaction.atomic 351 | def redo(self): 352 | actions = self.actions.order_by('-order') 353 | 354 | for action in actions: 355 | action.redo() 356 | self.is_applied = True 357 | self.save(update_fields=['is_applied']) 358 | signals.post_operation_redo.send( 359 | sender=self.__class__, 360 | operation=self, 361 | actions=actions, 362 | ) 363 | 364 | 365 | class PlaceholderAction(models.Model): 366 | ACTION_CHOICES = ( 367 | (actions.ADD_PLUGIN, 'Add plugin'), 368 | (actions.CHANGE_PLUGIN, 'Change plugin'), 369 | (actions.DELETE_PLUGIN, 'Delete plugin'), 370 | (actions.MOVE_PLUGIN, 'Move plugin'), 371 | (actions.MOVE_OUT_PLUGIN, 'Move out plugin'), 372 | (actions.MOVE_IN_PLUGIN, 'Move in plugin'), 373 | (actions.MOVE_PLUGIN_OUT_TO_CLIPBOARD, 'Move out to clipboard'), 374 | (actions.MOVE_PLUGIN_IN_TO_CLIPBOARD, 'Move in to clipboard'), 375 | (actions.ADD_PLUGINS_FROM_PLACEHOLDER, 'Add plugins from placeholder'), 376 | (actions.PASTE_PLUGIN, 'Paste plugin'), 377 | (actions.PASTE_PLACEHOLDER, 'Paste placeholder'), 378 | (actions.CLEAR_PLACEHOLDER, 'Clear placeholder'), 379 | ) 380 | 381 | action = models.CharField(max_length=30, choices=ACTION_CHOICES) 382 | pre_action_data = models.TextField(blank=True) 383 | post_action_data = models.TextField(blank=True) 384 | placeholder = models.ForeignKey(to=Placeholder, on_delete=models.CASCADE) 385 | language = models.CharField(max_length=15, choices=settings.LANGUAGES) 386 | operation = models.ForeignKey(to=PlaceholderOperation, related_name='actions', on_delete=models.CASCADE) 387 | order = models.PositiveIntegerField(default=1) 388 | 389 | class Meta: 390 | ordering = ['order'] 391 | unique_together = ('operation', 'order') 392 | 393 | def _object_version_data_hook(self, data): 394 | if isinstance(data, dict) and 'pk' in data and 'plugin_type' in data and 'position' in data: 395 | return ArchivedPlugin(**data) 396 | return data 397 | 398 | def _get_parsed_data(self, raw_data): 399 | data = json.loads( 400 | raw_data, 401 | object_hook=self._object_version_data_hook, 402 | ) 403 | return data 404 | 405 | def get_pre_action_data(self): 406 | return self._get_parsed_data(self.pre_action_data) 407 | 408 | def get_post_action_data(self): 409 | return self._get_parsed_data(self.post_action_data) 410 | 411 | @transaction.atomic 412 | def undo(self): 413 | _action_handlers[self.action]['undo'](self) 414 | 415 | @transaction.atomic 416 | def redo(self): 417 | _action_handlers[self.action]['redo'](self) 418 | -------------------------------------------------------------------------------- /djangocms_history/operation_handlers.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | from . import actions 4 | from .helpers import get_bound_plugins, get_plugin_data 5 | 6 | 7 | def _with_callback(func): 8 | """ 9 | Runs an operation callback defined in the plugin class 10 | for the given operation handler. 11 | """ 12 | def wrapped(*args, **kwargs): 13 | func(*args, **kwargs) 14 | 15 | if 'new_plugin' in kwargs: 16 | plugin = kwargs['new_plugin'] 17 | else: 18 | plugin = kwargs['plugin'] 19 | 20 | PluginClass = plugin.get_plugin_class() 21 | 22 | try: 23 | callbacks = PluginClass.operation_handler_callbacks 24 | callback = callbacks[func.__name__] 25 | callback(*args, **kwargs) 26 | except (AttributeError, KeyError): 27 | pass 28 | return wrapped 29 | 30 | 31 | @_with_callback 32 | def pre_add_plugin(operation, **kwargs): 33 | # Stores the ID of the parent plugin where the new plugin 34 | # will be created and the original order of the tree 35 | # where this plugin is being added 36 | plugin = kwargs['plugin'] 37 | action_data = { 38 | 'parent_id': plugin.parent_id, 39 | 'order': kwargs['tree_order'], 40 | } 41 | 42 | operation.create_action( 43 | action=actions.ADD_PLUGIN, 44 | language=plugin.language, 45 | placeholder=kwargs['placeholder'], 46 | pre_data=action_data, 47 | ) 48 | 49 | 50 | @_with_callback 51 | def post_add_plugin(operation, **kwargs): 52 | # Stores the ID of the parent plugin where the new plugin 53 | # will be created, the plugin data for the new created plugin, 54 | # and the new order of the tree where this plugin is being added 55 | plugin = kwargs['plugin'] 56 | action_data = { 57 | 'parent_id': plugin.parent_id, 58 | 'plugins': [get_plugin_data(plugin=plugin)], 59 | 'order': kwargs['tree_order'], 60 | } 61 | 62 | operation.set_post_action_data(action=actions.ADD_PLUGIN, data=action_data) 63 | 64 | 65 | @_with_callback 66 | def pre_change_plugin(operation, **kwargs): 67 | # Stores 68 | # * the plugin data before any updates 69 | 70 | plugin = kwargs['old_plugin'] 71 | action_data = {'plugins': [get_plugin_data(plugin=plugin)]} 72 | 73 | operation.create_action( 74 | action=actions.CHANGE_PLUGIN, 75 | language=plugin.language, 76 | placeholder=kwargs['placeholder'], 77 | pre_data=action_data, 78 | ) 79 | 80 | 81 | @_with_callback 82 | def post_change_plugin(operation, **kwargs): 83 | # Stores 84 | # * the plugin data with updates already applied 85 | 86 | plugin = kwargs['new_plugin'] 87 | action_data = { 88 | 'plugins': [get_plugin_data(plugin=plugin)] 89 | } 90 | 91 | operation.set_post_action_data( 92 | action=actions.CHANGE_PLUGIN, 93 | data=action_data, 94 | ) 95 | 96 | 97 | @_with_callback 98 | def pre_delete_plugin(operation, **kwargs): 99 | # Stores 100 | # * the id of the parent plugin for the plugin being deleted 101 | # * plugin data for the plugin being deleted and all its descendants. 102 | # * the tree order before the plugin got deleted 103 | 104 | get_data = get_plugin_data 105 | 106 | plugin = kwargs['plugin'] 107 | descendants = plugin.get_descendants().order_by('path') 108 | plugin_data = [get_data(plugin=plugin)] 109 | plugin_data.extend(get_data(plugin) for plugin in get_bound_plugins(descendants)) 110 | 111 | action_data = { 112 | 'parent_id': plugin.parent_id, 113 | 'plugins': plugin_data, 114 | 'order': kwargs['tree_order'], 115 | } 116 | 117 | operation.create_action( 118 | action=actions.DELETE_PLUGIN, 119 | language=plugin.language, 120 | placeholder=kwargs['placeholder'], 121 | pre_data=action_data, 122 | ) 123 | 124 | 125 | @_with_callback 126 | def post_delete_plugin(operation, **kwargs): 127 | # Stores 128 | # * the id of the parent plugin for the deleted plugin 129 | # * plugin meta data for the deleted plugin 130 | # * the tree order after the plugin got deleted 131 | 132 | plugin = kwargs['plugin'] 133 | plugin_data = [get_plugin_data(plugin=plugin, only_meta=True)] 134 | action_data = { 135 | 'order': kwargs['tree_order'], 136 | 'parent_id': plugin.parent_id, 137 | 'plugins': plugin_data, 138 | } 139 | 140 | operation.set_post_action_data(action=actions.DELETE_PLUGIN, data=action_data) 141 | 142 | 143 | def pre_move_plugin(operation, **kwargs): 144 | # Action 1 Stores 145 | # * the tree order of the source placeholder before the plugin is moved 146 | # * the id of the parent plugin for the plugin being moved 147 | # * plugin meta data for the plugin being moved 148 | 149 | action_data = { 150 | 'order': kwargs['source_order'], 151 | 'parent_id': kwargs['source_parent_id'], 152 | 'plugins': [get_plugin_data(plugin=kwargs['plugin'], only_meta=True)], 153 | } 154 | 155 | move_out = kwargs['source_placeholder'] != kwargs['target_placeholder'] 156 | 157 | if move_out: 158 | action = actions.MOVE_OUT_PLUGIN 159 | else: 160 | action = actions.MOVE_PLUGIN 161 | 162 | operation.create_action( 163 | action=action, 164 | language=kwargs['source_language'], 165 | placeholder=kwargs['source_placeholder'], 166 | pre_data=action_data, 167 | ) 168 | 169 | # Action 2 Stores 170 | # * the tree order of the target placeholder before the plugin is moved 171 | # * the id of the new parent plugin 172 | 173 | if move_out: 174 | action_data = { 175 | 'order': kwargs['target_order'], 176 | 'parent_id': kwargs['target_parent_id'], 177 | } 178 | 179 | operation.create_action( 180 | action=actions.MOVE_IN_PLUGIN, 181 | language=kwargs['target_language'], 182 | placeholder=kwargs['target_placeholder'], 183 | pre_data=action_data, 184 | order=2, 185 | ) 186 | 187 | 188 | def post_move_plugin(operation, **kwargs): 189 | # Action 1 Stores 190 | # * the tree order of the target placeholder after the plugin was moved 191 | # * the id of the new parent plugin 192 | # * plugin meta data for the moved plugin 193 | 194 | action_data = { 195 | 'order': kwargs['target_order'], 196 | 'parent_id': kwargs['target_parent_id'], 197 | 'plugins': [get_plugin_data(plugin=kwargs['plugin'], only_meta=True)], 198 | } 199 | 200 | move_in = kwargs['source_placeholder'] != kwargs['target_placeholder'] 201 | 202 | if move_in: 203 | action = actions.MOVE_IN_PLUGIN 204 | else: 205 | action = actions.MOVE_PLUGIN 206 | 207 | operation.set_post_action_data(action=action, data=action_data) 208 | 209 | # Action 2 Stores 210 | # * the tree order of the source placeholder after the plugin was moved 211 | # * the id of the old parent plugin 212 | 213 | if move_in: 214 | action_data = { 215 | 'order': kwargs['source_order'], 216 | 'parent_id': kwargs['source_parent_id'], 217 | } 218 | operation.set_post_action_data(action=actions.MOVE_OUT_PLUGIN, data=action_data) 219 | 220 | 221 | def pre_paste_plugin(operation, **kwargs): 222 | # Stores 223 | # * the tree order of the target placeholder before the plugin is pasted 224 | 225 | action_data = {'order': kwargs['target_order']} 226 | 227 | operation.create_action( 228 | action=actions.PASTE_PLUGIN, 229 | language=kwargs['target_language'], 230 | placeholder=kwargs['target_placeholder'], 231 | pre_data=action_data, 232 | ) 233 | 234 | 235 | def post_paste_plugin(operation, **kwargs): 236 | # Stores 237 | # * the tree order of the target placeholder after the plugin was pasted 238 | # * the id of the new parent plugin 239 | # * plugin data for the pasted plugin and all its descendants 240 | 241 | get_data = get_plugin_data 242 | 243 | plugin = kwargs['plugin'] 244 | descendants = plugin.get_descendants().order_by('path') 245 | plugin_data = [get_data(plugin=plugin)] 246 | plugin_data.extend(get_data(plugin) for plugin in get_bound_plugins(descendants)) 247 | action_data = { 248 | 'order': kwargs['target_order'], 249 | 'parent_id': kwargs['target_parent_id'], 250 | 'plugins': plugin_data, 251 | } 252 | 253 | operation.set_post_action_data(action=actions.PASTE_PLUGIN, data=action_data) 254 | 255 | 256 | def pre_paste_placeholder(operation, **kwargs): 257 | # Stores 258 | # * the tree order of the target placeholder before the plugins are pasted 259 | 260 | action_data = { 261 | 'order': kwargs['target_order'], 262 | } 263 | 264 | operation.create_action( 265 | action=actions.PASTE_PLACEHOLDER, 266 | language=kwargs['target_language'], 267 | placeholder=kwargs['target_placeholder'], 268 | pre_data=action_data, 269 | ) 270 | 271 | 272 | def post_paste_placeholder(operation, **kwargs): 273 | # Stores 274 | # * the tree order of the target placeholder after the plugins were pasted 275 | # * plugin data for the pasted plugins 276 | 277 | get_data = get_plugin_data 278 | plugins = get_bound_plugins(kwargs['plugins']) 279 | plugin_data = [get_data(plugin=plugin) for plugin in plugins] 280 | action_data = {'order': kwargs['target_order'], 'plugins': plugin_data} 281 | operation.set_post_action_data(action=actions.PASTE_PLACEHOLDER, data=action_data) 282 | 283 | 284 | def pre_cut_plugin(operation, **kwargs): 285 | # Action 1 Stores 286 | # * plugin meta data for the cut plugin 287 | 288 | get_data = get_plugin_data 289 | 290 | plugin = kwargs['plugin'] 291 | plugin_data = [get_data(plugin=plugin, only_meta=True)] 292 | action_data = {'plugins': plugin_data} 293 | 294 | operation.create_action( 295 | action=actions.MOVE_PLUGIN_IN_TO_CLIPBOARD, 296 | language=kwargs['clipboard_language'], 297 | placeholder=kwargs['clipboard'], 298 | post_data=action_data, 299 | order=1, 300 | ) 301 | 302 | # Action 2 Stores 303 | # * the tree order of the source placeholder before the plugin is cut 304 | # * the id of the parent plugin for the plugin being cut 305 | # * plugin data for the plugin being cut and all its descendants 306 | 307 | descendants = plugin.get_descendants().order_by('path') 308 | 309 | plugins = [plugin] 310 | plugins.extend(get_bound_plugins(descendants)) 311 | 312 | plugin_data = [get_data(plugin=plugin) for plugin in plugins] 313 | action_data = { 314 | 'order': kwargs['source_order'], 315 | 'parent_id': kwargs['source_parent_id'], 316 | 'plugins': plugin_data, 317 | } 318 | 319 | operation.create_action( 320 | action=actions.MOVE_PLUGIN_OUT_TO_CLIPBOARD, 321 | language=kwargs['source_language'], 322 | placeholder=kwargs['source_placeholder'], 323 | pre_data=action_data, 324 | order=2, 325 | ) 326 | 327 | 328 | def post_cut_plugin(operation, **kwargs): 329 | # Stores 330 | # * the tree order of the target placeholder after the plugin was cut 331 | # * the id of the parent plugin for the cut plugin 332 | 333 | action_data = { 334 | 'order': kwargs['source_order'], 335 | 'parent_id': kwargs['source_parent_id'], 336 | } 337 | 338 | operation.set_post_action_data( 339 | action=actions.MOVE_PLUGIN_OUT_TO_CLIPBOARD, 340 | data=action_data, 341 | ) 342 | 343 | 344 | def pre_add_plugins_from_placeholder(operation, **kwargs): 345 | # Stores 346 | # * plugin data for the pasted plugins 347 | 348 | action_data = {'order': kwargs['target_order']} 349 | 350 | operation.create_action( 351 | action=actions.ADD_PLUGINS_FROM_PLACEHOLDER, 352 | language=kwargs['target_language'], 353 | placeholder=kwargs['target_placeholder'], 354 | pre_data=action_data, 355 | ) 356 | 357 | 358 | def post_add_plugins_from_placeholder(operation, **kwargs): 359 | # Stores 360 | # * plugin data for the new plugins 361 | # * the tree order of the target placeholder after the new plugins are added 362 | 363 | get_data = get_plugin_data 364 | plugins = get_bound_plugins(kwargs['plugins']) 365 | plugin_data = [get_data(plugin=plugin) for plugin in plugins] 366 | action_data = { 367 | 'plugins': plugin_data, 368 | 'order': kwargs['target_order'] 369 | } 370 | 371 | operation.set_post_action_data( 372 | action=actions.ADD_PLUGINS_FROM_PLACEHOLDER, 373 | data=action_data, 374 | ) 375 | 376 | 377 | def pre_clear_placeholder(operation, **kwargs): 378 | # Stores 379 | # * plugin data for all the plugins being deleted 380 | 381 | get_data = get_plugin_data 382 | plugins = get_bound_plugins(kwargs['plugins']) 383 | plugin_data = [get_data(plugin=plugin) for plugin in plugins] 384 | action_data = {'plugins': plugin_data} 385 | 386 | operation.create_action( 387 | action=actions.CLEAR_PLACEHOLDER, 388 | language=operation.language, 389 | placeholder=kwargs['placeholder'], 390 | pre_data=action_data, 391 | ) 392 | 393 | 394 | def post_clear_placeholder(operation, **kwargs): 395 | # Stores 396 | # * plugin meta data for all the deleted parent less plugins 397 | 398 | get_data = partial(get_plugin_data, only_meta=True) 399 | root_plugins = [get_data(plugin=plugin) for plugin in kwargs['plugins'] 400 | if not plugin.parent_id] 401 | action_data = {'plugins': root_plugins} 402 | 403 | operation.set_post_action_data(action=actions.CLEAR_PLACEHOLDER, data=action_data) 404 | -------------------------------------------------------------------------------- /djangocms_history/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | post_operation_undo = Signal("operation actions") 5 | 6 | post_operation_redo = Signal("operation actions") 7 | -------------------------------------------------------------------------------- /djangocms_history/static/djangocms_history/color_mode.css: -------------------------------------------------------------------------------- 1 | html[data-theme="dark"] .history-button.undo { 2 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAM9JREFUWEftlksOwCAIRPW0nsnTtnFBYqwwg/GTJnYrn+dAwRgOf/Fw/nABXArknB+mZCklOu7HsE4igdjELRwDYgIwt0U2CGI5QAG0ILYAWBBDANqNUK/0/NwAqKblthrIFABUU2lKFsKtwG8AtFK0ZaAnFvrfe+e9MlyAq8A2BYbnQNvNEoiZgLXvFIA2CAvBJi/A6hywFsvSZYRm+chAcq1jpo5eCNeDRGtCb1KxR31D7QL00NDgUHKzCdnl0rNjEosfpcCo/IzfBTiuwAuFfoQhCFHtvgAAAABJRU5ErkJggg=='); 3 | } 4 | html[data-theme="light"] .history-button.undo { 5 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABOklEQVR4Ae3WNVZDQRhAYdyhimcBeMsG6CI1C8Dd9pK0rACXCm+QDoeaFnfnFlNNns4bnHvO10T/55Plsv/+i6ITMzjCtXCAOfFeBJ5axCb80h+n8YA3G49IIwSl3oRtBBDHKd5cukBSeQDhGC94U/SCXvUBjO2gF9UoFWoxiF2TIZI6BrhHF3JgVq4Y7sHgcIS8DnCEAJzUaDBESsch2IYfTuo1uDoiOs6BdTgp1+Cc6IBtqzYDLMBpg9J3p/Gp1UoDHOBTK5MGuMKnViFfjn/uEAx95UmofBnKFaAKbuszuBGFVf58Ag9IgLzfip2WjzHpYTSIXIWH0TmCcFUDXk0ex8OoRwXKUIchi8dxAkq1fMWCRK5JcUl2jgS0FMAInh0uSlPyMdc5SBdmsItLXGEfM+hwe6n99987pVTygpWk5ykAAAAASUVORK5CYII='); 6 | } 7 | 8 | html[data-theme="dark"] .history-button.redo { 9 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAM5JREFUWEftlkEOwyAMBJPX8iZe24gDUkTt7Nikogdyxt5hvTI5j8XfuVj/2AAhB2qtHzKyUgruiw5S4RGug9zrRzgJkBX3nAoBvC3eoDDAL8QxgBL3QqbqpgFIuhWEHIHX4A1x5MBfApDbt9sp+9MOUACyJcczX4vIusUG2A5Y6c0EzqpBIcwA9DCr8ZnP8cwysvbBE0QIoFuYeYy8GveHhGy1SA7CAHS1EojwCO5NZ51IhXC8VRZCiTcd+VOacYMI974hADLv6JkNsNyBC7XYhCHbOi9lAAAAAElFTkSuQmCC'); 10 | } 11 | html[data-theme="light"] .history-button.redo { 12 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABP0lEQVR42u3WP0vDQBiAcSXoYMWtpcYPoFjd3R01rn4Alx40Q9vv0q5+AxGsbiKOuoharM7OQv8MVqzng3Q6Ei5vLmQxD/zGcC9ckruFoiJhPhSuMMBk7g0XUNhA5q2jiy9oiyk6xiBl3OMG4gKMoIU+cIAKnqABWSFm0CnN8A4NQNBRzOJ9NFFDCSvYRB0PtqEkez6M2NsGPMS1CIVP1wG6EYvvI0llDFwG8CPe9oZg8UfXLVARe+4hSXdZvAOXxgNNJO3asvgtrL0aD20j18bGAKv/bgBzC2rILflLKM/9M3RP/iMKIW0Ly7Am/xXbCzDFedohqjGHUWjZDg8t4zA6wxLEBYg7jlvYQQlr2EUbz9CGH+xhXv4XkhM4dYhhyivZMTKpKriUfuMUFWSejzp6eMEYI/TRg/pbuKhI0C8BZPKBEtpCEgAAAABJRU5ErkJggg=='); 13 | } 14 | 15 | 16 | @media (prefers-color-scheme: light) { 17 | html[data-theme="auto"] .history-button.undo, html:not([data-theme]) .history-button.undo { 18 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABOklEQVR4Ae3WNVZDQRhAYdyhimcBeMsG6CI1C8Dd9pK0rACXCm+QDoeaFnfnFlNNns4bnHvO10T/55Plsv/+i6ITMzjCtXCAOfFeBJ5axCb80h+n8YA3G49IIwSl3oRtBBDHKd5cukBSeQDhGC94U/SCXvUBjO2gF9UoFWoxiF2TIZI6BrhHF3JgVq4Y7sHgcIS8DnCEAJzUaDBESsch2IYfTuo1uDoiOs6BdTgp1+Cc6IBtqzYDLMBpg9J3p/Gp1UoDHOBTK5MGuMKnViFfjn/uEAx95UmofBnKFaAKbuszuBGFVf58Ag9IgLzfip2WjzHpYTSIXIWH0TmCcFUDXk0ex8OoRwXKUIchi8dxAkq1fMWCRK5JcUl2jgS0FMAInh0uSlPyMdc5SBdmsItLXGEfM+hwe6n99987pVTygpWk5ykAAAAASUVORK5CYII='); 19 | } 20 | html[data-theme="auto"] .history-button.redo, html:not([data-theme]) .history-button.redo { 21 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABP0lEQVR42u3WP0vDQBiAcSXoYMWtpcYPoFjd3R01rn4Alx40Q9vv0q5+AxGsbiKOuoharM7OQv8MVqzng3Q6Ei5vLmQxD/zGcC9ckruFoiJhPhSuMMBk7g0XUNhA5q2jiy9oiyk6xiBl3OMG4gKMoIU+cIAKnqABWSFm0CnN8A4NQNBRzOJ9NFFDCSvYRB0PtqEkez6M2NsGPMS1CIVP1wG6EYvvI0llDFwG8CPe9oZg8UfXLVARe+4hSXdZvAOXxgNNJO3asvgtrL0aD20j18bGAKv/bgBzC2rILflLKM/9M3RP/iMKIW0Ly7Am/xXbCzDFedohqjGHUWjZDg8t4zA6wxLEBYg7jlvYQQlr2EUbz9CGH+xhXv4XkhM4dYhhyivZMTKpKriUfuMUFWSejzp6eMEYI/TRg/pbuKhI0C8BZPKBEtpCEgAAAABJRU5ErkJggg=='); 22 | } 23 | } 24 | 25 | @media (prefers-color-scheme: dark) { 26 | html[data-theme="auto"] .undo, html:not([data-theme]) .undo { 27 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAM9JREFUWEftlksOwCAIRPW0nsnTtnFBYqwwg/GTJnYrn+dAwRgOf/Fw/nABXArknB+mZCklOu7HsE4igdjELRwDYgIwt0U2CGI5QAG0ILYAWBBDANqNUK/0/NwAqKblthrIFABUU2lKFsKtwG8AtFK0ZaAnFvrfe+e9MlyAq8A2BYbnQNvNEoiZgLXvFIA2CAvBJi/A6hywFsvSZYRm+chAcq1jpo5eCNeDRGtCb1KxR31D7QL00NDgUHKzCdnl0rNjEosfpcCo/IzfBTiuwAuFfoQhCFHtvgAAAABJRU5ErkJggg=='); 28 | } 29 | html[data-theme="auto"] .redo, html:not([data-theme]) .redo { 30 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAM5JREFUWEftlkEOwyAMBJPX8iZe24gDUkTt7Nikogdyxt5hvTI5j8XfuVj/2AAhB2qtHzKyUgruiw5S4RGug9zrRzgJkBX3nAoBvC3eoDDAL8QxgBL3QqbqpgFIuhWEHIHX4A1x5MBfApDbt9sp+9MOUACyJcczX4vIusUG2A5Y6c0EzqpBIcwA9DCr8ZnP8cwysvbBE0QIoFuYeYy8GveHhGy1SA7CAHS1EojwCO5NZ51IhXC8VRZCiTcd+VOacYMI974hADLv6JkNsNyBC7XYhCHbOi9lAAAAAElFTkSuQmCC'); 31 | } 32 | } 33 | 34 | 35 | .history-button.redo, .history-button.undo { 36 | background-position: center; 37 | background-size: 16px 16px; 38 | background-repeat: no-repeat; 39 | width: 16px; 40 | height: 16px; 41 | margin-top: 7px; 42 | } 43 | .history-button.redo.disabled, .history-button.undo.disabled { 44 | opacity: 0.2; 45 | } 46 | -------------------------------------------------------------------------------- /djangocms_history/templates/djangocms_history/toolbar/ajax_button.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 |
7 | -------------------------------------------------------------------------------- /djangocms_history/utils.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from cms.plugin_pool import plugin_pool 4 | 5 | 6 | @lru_cache() 7 | def get_plugin_fields(plugin_type): 8 | klass = get_plugin_class(plugin_type) 9 | opts = klass.model._meta.concrete_model._meta 10 | fields = opts.local_fields + opts.local_many_to_many 11 | return [field.name for field in fields] 12 | 13 | 14 | @lru_cache() 15 | def get_plugin_class(plugin_type): 16 | return plugin_pool.get_plugin(plugin_type) 17 | 18 | 19 | @lru_cache() 20 | def get_plugin_model(plugin_type): 21 | return get_plugin_class(plugin_type).model 22 | -------------------------------------------------------------------------------- /djangocms_history/views.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import PermissionDenied 2 | from django.http import HttpResponse, HttpResponseBadRequest 3 | from django.views.generic import DetailView 4 | 5 | from .forms import UndoRedoForm 6 | from .helpers import ( 7 | get_active_operation, get_inactive_operation, get_operations_from_request, 8 | ) 9 | from .models import PlaceholderOperation 10 | 11 | 12 | class UndoRedoView(DetailView): 13 | action = None 14 | model = PlaceholderOperation 15 | http_method_names = ['post'] 16 | form_class = UndoRedoForm 17 | 18 | def post(self, request, *args, **kwargs): 19 | user = request.user 20 | 21 | if not (user.is_active and user.is_staff): 22 | raise PermissionDenied 23 | 24 | self.form = self.form_class(request.POST) 25 | 26 | if not self.form.is_valid(): 27 | return HttpResponseBadRequest('No operation found') 28 | 29 | self.object = self.get_object() 30 | 31 | if not self.object: 32 | return HttpResponseBadRequest('No operation found') 33 | 34 | if self.action == 'undo': 35 | self.object.undo() 36 | else: 37 | self.object.redo() 38 | return HttpResponse(status=204) 39 | 40 | def get_object(self, queryset=None): 41 | if queryset is None: 42 | queryset = self.get_queryset() 43 | 44 | if self.action == 'undo': 45 | operation = get_active_operation(queryset) 46 | else: 47 | operation = get_inactive_operation(queryset) 48 | return operation 49 | 50 | def get_queryset(self): 51 | data = self.form.cleaned_data 52 | queryset = get_operations_from_request( 53 | self.request, 54 | path=data['cms_path'], 55 | language=data['language'], 56 | ) 57 | return queryset 58 | 59 | 60 | undo = UndoRedoView.as_view(action='undo') 61 | redo = UndoRedoView.as_view(action='redo') 62 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-history/b675553820f44fe629f67487392946d305a6001b/preview.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup 3 | 4 | from djangocms_history import __version__ 5 | 6 | 7 | REQUIREMENTS = [ 8 | 'django-cms>=3.7', 9 | ] 10 | 11 | 12 | CLASSIFIERS = [ 13 | 'Development Status :: 5 - Production/Stable', 14 | 'Environment :: Web Environment', 15 | 'Intended Audience :: Developers', 16 | 'License :: OSI Approved :: BSD License', 17 | 'Operating System :: OS Independent', 18 | 'Programming Language :: Python', 19 | 'Programming Language :: Python :: 3', 20 | 'Programming Language :: Python :: 3.5', 21 | 'Programming Language :: Python :: 3.6', 22 | 'Programming Language :: Python :: 3.7', 23 | 'Programming Language :: Python :: 3.8', 24 | 'Framework :: Django', 25 | 'Framework :: Django :: 2.2', 26 | 'Framework :: Django :: 3.0', 27 | 'Framework :: Django :: 3.1', 28 | 'Framework :: Django CMS', 29 | 'Framework :: Django CMS :: 3.7', 30 | 'Framework :: Django CMS :: 3.8', 31 | 'Topic :: Internet :: WWW/HTTP', 32 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 33 | 'Topic :: Software Development', 34 | 'Topic :: Software Development :: Libraries', 35 | ] 36 | 37 | 38 | setup( 39 | name='djangocms-history', 40 | version=__version__, 41 | author='Divio AG', 42 | author_email='info@divio.ch', 43 | maintainer='Django CMS Association and contributors', 44 | maintainer_email='info@django-cms.org', 45 | url='https://github.com/django-cms/djangocms-history', 46 | license='BSD-3-Clause', 47 | description='Adds undo/redo functionality to django CMS', 48 | long_description=open('README.rst').read(), 49 | packages=find_packages(), 50 | include_package_data=True, 51 | zip_safe=False, 52 | install_requires=REQUIREMENTS, 53 | classifiers=CLASSIFIERS, 54 | test_suite='tests.settings.run', 55 | ) 56 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-cms/djangocms-history/b675553820f44fe629f67487392946d305a6001b/tests/__init__.py -------------------------------------------------------------------------------- /tests/requirements/base.txt: -------------------------------------------------------------------------------- 1 | #other requirements 2 | django-app-helper 3 | tox 4 | coverage 5 | isort 6 | flake8 -------------------------------------------------------------------------------- /tests/requirements/dj22_cms37.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=2.2,<3.0 4 | django-cms>=3.7,<3.8 5 | -------------------------------------------------------------------------------- /tests/requirements/dj22_cms38.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=2.2,<3.0 4 | django-cms>=3.8,<3.9 5 | -------------------------------------------------------------------------------- /tests/requirements/dj30_cms37.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=3.0,<3.1 4 | django-cms>=3.7,<3.8 5 | -------------------------------------------------------------------------------- /tests/requirements/dj30_cms38.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=3.0,<3.1 4 | django-cms>=3.8,<3.9 5 | -------------------------------------------------------------------------------- /tests/requirements/dj31_cms38.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | Django>=3.1,<3.2 4 | django-cms>=3.8,<3.9 5 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | HELPER_SETTINGS = { 3 | 'INSTALLED_APPS': [ 4 | 'djangocms_history', 5 | ], 6 | 'CMS_LANGUAGES': { 7 | 1: [{ 8 | 'code': 'en', 9 | 'name': 'English', 10 | }] 11 | }, 12 | 'LANGUAGE_CODE': 'en', 13 | 'ALLOWED_HOSTS': ['localhost'], 14 | } 15 | 16 | 17 | def run(): 18 | from app_helper import runner 19 | runner.cms('djangocms_history') 20 | 21 | 22 | if __name__ == '__main__': 23 | run() 24 | -------------------------------------------------------------------------------- /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 | 11 | @override_settings(MIGRATION_MODULES={}) 12 | def test_for_missing_migrations(self): 13 | output = StringIO() 14 | options = { 15 | 'interactive': False, 16 | 'dry_run': True, 17 | 'stdout': output, 18 | 'check_changes': True, 19 | } 20 | 21 | try: 22 | call_command('makemigrations', **options) 23 | except SystemExit as e: 24 | status_code = str(e) 25 | else: 26 | # the "no changes" exit code is 0 27 | status_code = '0' 28 | 29 | if status_code == '1': 30 | self.fail('There are missing migrations:\n {}'.format(output.getvalue())) 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | flake8 4 | isort 5 | py{35,36,37,38}-dj{22}-cms{37,38} 6 | py{36,37,38}-dj{30}-cms{37,38} 7 | py{36,37,38}-dj{31}-cms{38} 8 | 9 | skip_missing_interpreters=True 10 | 11 | [flake8] 12 | max-line-length = 119 13 | exclude = 14 | *.egg-info, 15 | .eggs, 16 | .git, 17 | .settings, 18 | .tox, 19 | build, 20 | data, 21 | dist, 22 | docs, 23 | *migrations*, 24 | requirements, 25 | tmp 26 | 27 | [isort] 28 | line_length = 79 29 | skip = manage.py, *migrations*, .tox, .eggs, data 30 | include_trailing_comma = true 31 | multi_line_output = 5 32 | not_skip = __init__.py 33 | lines_after_imports = 2 34 | default_section = THIRDPARTY 35 | sections = FUTURE, STDLIB, DJANGO, CMS, THIRDPARTY, FIRSTPARTY, LIB, LOCALFOLDER 36 | known_first_party = djangocms_history 37 | known_cms = cms, menus 38 | known_django = django 39 | 40 | [testenv] 41 | deps = 42 | -r{toxinidir}/tests/requirements/base.txt 43 | dj22: Django>=2.2,<3.0 44 | dj30: Django>=3.0,<3.1 45 | dj31: Django>=3.1,<3.2 46 | cms37: django-cms>=3.7,<3.8 47 | cms38: django-cms>=3.8,<3.9 48 | commands = 49 | {envpython} --version 50 | {env:COMMAND:coverage} erase 51 | {env:COMMAND:coverage} run setup.py test 52 | {env:COMMAND:coverage} report 53 | 54 | [testenv:flake8] 55 | deps = flake8 56 | commands = flake8 57 | 58 | [testenv:isort] 59 | deps = isort 60 | commands = isort -c -rc -df djangocms_history 61 | skip_install = true 62 | --------------------------------------------------------------------------------