diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..14f01789 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ceac2fc8..9f05d488 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,19 +24,19 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 if: ${{ matrix.language == 'javascript' || matrix.language == 'python' }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6412cc0f..f2dee668 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,12 +14,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' - name: Cache dependencies - uses: actions/cache@v3.3.1 + uses: actions/cache@v4.1.2 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }} @@ -39,12 +39,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' - name: Cache dependencies - uses: actions/cache@v3.3.1 + uses: actions/cache@v4.1.2 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8687ed46..8878fd89 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,26 +1,17 @@ -name: Lint +name: Ruff -on: [push, pull_request] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +on: + push: + pull_request: jobs: ruff: - name: ruff runs-on: ubuntu-latest + steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - cache: 'pip' - - run: | - python -m pip install --upgrade pip - pip install ruff - - name: Run Ruff - run: | - ruff djangocms_versioning tests + - uses: actions/checkout@v4 + + - run: python -Im pip install --user ruff + + - name: Run ruff + run: ruff check --output-format=github djangocms_versioning tests diff --git a/.github/workflows/publish-to-live-pypi.yml b/.github/workflows/publish-to-live-pypi.yml index 2d8272e2..3208e6bb 100644 --- a/.github/workflows/publish-to-live-pypi.yml +++ b/.github/workflows/publish-to-live-pypi.yml @@ -15,9 +15,9 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index fd1cf6fb..32ddf41a 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -15,9 +15,9 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5897902..c4315c7a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,24 +7,29 @@ concurrency: cancel-in-progress: true jobs: - sqlite-unit-tests: + sqlite: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, - dj40_cms41.txt, - dj41_cms41.txt, dj42_cms41.txt, + dj50_cms41.txt, + dj51_cms41.txt, ] + exclude: + - requirements-file: dj50_cms41.txt + python-version: 3.9 + - requirements-file: dj51_cms41.txt + python-version: 3.9 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -34,27 +39,32 @@ jobs: python setup.py install - name: Run coverage - run: coverage run setup.py test + run: coverage run ./test_settings.py - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 - postgres-unit-tests: + postgres: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, - dj40_cms41.txt, - dj41_cms41.txt, dj42_cms41.txt, + dj50_cms41.txt, + dj51_cms41.txt, ] + exclude: + - requirements-file: dj50_cms41.txt + python-version: 3.9 + - requirements-file: dj51_cms41.txt + python-version: 3.9 services: postgres: - image: postgres:12 + image: postgres:13 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -65,10 +75,10 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -78,25 +88,30 @@ jobs: python setup.py install - name: Run coverage - run: coverage run setup.py test + run: coverage run ./test_settings.py env: DATABASE_URL: postgres://postgres:postgres@127.0.0.1/postgres - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 - mysql-unit-tests: + mysql: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, - dj40_cms41.txt, - dj41_cms41.txt, dj42_cms41.txt, + dj50_cms41.txt, + dj51_cms41.txt, ] + exclude: + - requirements-file: dj50_cms41.txt + python-version: 3.9 + - requirements-file: dj51_cms41.txt + python-version: 3.9 services: mysql: @@ -109,10 +124,10 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -122,9 +137,79 @@ jobs: python setup.py install - name: Run coverage - run: coverage run setup.py test + run: coverage run ./test_settings.py env: DATABASE_URL: mysql://root@127.0.0.1/djangocms_test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 + + cms-develop-sqlite: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ['3.11'] + requirements-file: ['dj51_cms41.txt'] + cms-version: [ + 'https://github.com/django-cms/django-cms/archive/develop-4.tar.gz' + ] + os: [ + ubuntu-20.04, + ] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r tests/requirements/${{ matrix.requirements-file }} + python -m pip install ${{ matrix.cms-version }} + python setup.py install + + - name: Run coverage + run: coverage run ./test_settings.py + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v4 + + sqlite-django-main: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.11" ] + cms-version: [ + 'https://github.com/django-cms/django-cms/archive/develop-4.tar.gz' + ] + django-version: [ + 'https://github.com/django/django/archive/main.tar.gz' + ] + requirements-file: [ + requirements_base.txt, + ] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r tests/requirements/${{ matrix.requirements-file }} + python -m pip install ${{ matrix.cms-version }} ${{ matrix.django-version }} + python setup.py install + + - name: Run coverage + run: coverage run ./test_settings.py + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ed8a6403..6922db3d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,8 +14,8 @@ repos: - id: check-merge-conflict - id: mixed-line-ending - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.264" + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.1 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..e1bd672f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,20 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + fail_on_warning: false + +formats: + - epub + - pdf + +python: + install: + - requirements: docs/requirements.txt diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 994c46d0..92f7272c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,36 +2,120 @@ Changelog ========= +2.1.0 (2024-07-12) +================== + +* feat: add support for Django 5.0 and 5.1 (#429) by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/429 +* feat: Add versioning actions to settings (admin change view) of versioned objects by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/408 +* fix: Remove workaround for page-specific rendering by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/411 +* fix: Compare versions' back button sometimes returns to invalid URL by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/413 + + +* feat: Add versioning actions to settings (admin change view) of versioned objects by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/408 +* feat: Optimize db evaluation by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/416 +* feat: Prefetch page content version objects for faster page tree by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/418 +* fix: Remove workaround for page-specific rendering by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/411 +* fix: Compare versions' back button sometimes returns to invalid URL by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/413 +* fix: Preparation for changes in django CMS 4.2 by @jrief in https://github.com/django-cms/djangocms-versioning/pull/419 +* fix: Unnecessary complexity in ``current_content`` query set by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/417 +* fix: get_page_content retrieved non page-content objects from the toolbar by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/423 + + +**Full Changelog**: https://github.com/django-cms/djangocms-versioning/compare/2.0.2...2.1.0 + +2.0.2 (2024-05-03) +================== + +* fix: Do not show edit action for version objects where editing is not possible by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/405 +* feat: Add Arabic locale + +2.0.1 (2024-03-29) +================== + +* feat: Add content object level publish permissions by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/390 +* fix: Create missing __init__.py in management folder by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/366 +* fix #363: Better UX in versioning listview by @jrief in https://github.com/django-cms/djangocms-versioning/pull/364 +* fix: Several fixes for the versioning forms: #382, #383, #384 by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/386 +* fix: For Django CMS 4.1.1 and later do not automatically register versioned CMS Menu by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/388 +* fix: Post requests from the side frame were sent to wrong URL by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/396 +* fix: Consistent use of action buttons by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/392 +* fix: Avoid duplication of placeholder checks for locked versions by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/393 +* ci: Add testing against django main by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/353 +* ci: Improve efficiency of ruff workflow by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/378 +* Chore: update ruff and pre-commit hook by @raffaellasuardini in https://github.com/django-cms/djangocms-versioning/pull/381 +* build(deps): bump actions/cache from 4.0.1 to 4.0.2 by @dependabot in https://github.com/django-cms/djangocms-versioning/pull/397 + +New Contributors + +* @raffaellasuardini made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/381 +* @jrief made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/364 + +2.0.0 (2023-12-29) +================== -Unreleased -========== -* feat: Reversable generic foreign key lookup from version -* fix: formatted files through ruff to fix tests -* fix: Remove version check when evaluating CMS PageContent objects - -2.0.0rc1 -======== -* fix: Only try modifying page language menu if it is present -* fix: Added ``related_name`` attribute to the ``content_type`` foreign key of the ``Version`` model. -* fix: burger menu adjusts to the design of django cms core dropdown -* fix: bug that showed an archived version as unpublished in some cases in the state indicator -* add: Dutch and French translations thanks to Stefan van den Eertwegh and François Palmierso -* add: transifex support, German translations -* add: Revert button as replacement for dysfunctional Edit button for unpublished - versions -* add: status indicators and drop down menus for django cms page tree -* fix: only offer languages for plugin copy with available content -* feat: Add support for Django 4.0, 4.1 and Python 3.10 and 3.11 -* fix: migrations for MySql -* ci: Updated isort params in lint workflow to meet current requirements. -* ci: Update actions to v3 where possible, and coverage to v2 due to v1 sunset in Feb -* ci: Remove ``os`` from test workflow matrix because it's unused -* ci: Added concurrency option to cancel in progress runs when new changes occur -* fix: Added setting to make the field to identify a user configurable in ``ExtendedVersionAdminMixin.get_queryset()`` to fix issue for custom user models with no ``username`` -* ci: Run tests on sqlite, mysql and postgres db - -* feat: Compatibility with page content extension changes to django-cms -* ci: Added basic linting pre-commit hooks +What's Changed +-------------- +* ci: Added concurrency to workflows by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/271 +* ci: Remove ``os`` from test workflow matrix by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/270 +* ci: Update actions to latest versions by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/269 +* ci: Update isort params for v5 by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/268 +* Add CodeQL workflow for GitHub code scanning by @lgtm-com in https://github.com/django-cms/djangocms-versioning/pull/297 +* feat: Django 4.0, 4.1 / Python 3.10/3.11, mysql support, running tests on sqlite, postgres and mysql by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/287 +* feat: Compat with cms page content extension changes by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/291 +* fix: Additional change missed in #291 by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/301 +* Add: Allow simple version management commands from the page tree indicator drop down menus by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/295 +* fix: Adds compatibility for User models with no username field [#292] by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/293 +* feat: Use same icons in page tree state indicators and Manage verisons by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/302 +* fix: Remove patching the django CMS core by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/300 +* fix: test requirements after removing the patching pattern by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/303 +* feat: add localization and transifex support by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/305 +* feat: Add management command to create version objects by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/304 +* feat: add Dutch translations, transifex integration file by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/306 +* feat: French localization by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/307 +* feat: Albanian localization, Transifex integration by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/308 +* Some fixed strings are now translatable by @svandeneertwegh in https://github.com/django-cms/djangocms-versioning/pull/310 +* Translate '/djangocms_versioning/locale/en/LC_MESSAGES/django.po' in 'de' by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/311 +* Translate '/djangocms_versioning/locale/en/LC_MESSAGES/django.po' in 'nl' by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/312 +* fix: translation inconsistencies by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/313 +* feat: Add preview button to view published mode by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/316 +* feat: Huge performance improvement for admin_manager by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/318 +* fix: Minor usability improvements by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/317 +* fix: update messages by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/321 +* Translate 'djangocms_versioning/locale/en/LC_MESSAGES/django.po' in 'de' by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/322 +* fix: deletion of version objects blocked by source fields by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/320 +* feat: allow reuse of status indicators by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/319 +* fix: burger menu to also work with new core icons by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/323 +* Translate 'djangocms_versioning/locale/en/LC_MESSAGES/django.po' in 'nl' by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/328 +* ci: Switch flake8 and isort for ruff by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/329 +* fix: Added related_name to version content type field by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/274 +* feat: Django 4.2, Django CMS 4.1.0rc2 compatibility, and version locking by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/326 +* Translations for djangocms_versioning/locale/en/LC_MESSAGES/django.po in de by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/330 +* Translations for djangocms_versioning/locale/en/LC_MESSAGES/django.po in nl by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/331 +* fix: Modify language menu for pages only if it is present by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/333 +* feat: Add pypi actions by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/335 +* feat: Reversable generic foreign key lookup from version by @Aiky30 in https://github.com/django-cms/djangocms-versioning/pull/241 +* Add caching to PageContent __bool__ by @stefanw in https://github.com/django-cms/djangocms-versioning/pull/346 +* Fix tests by @FinalAngel in https://github.com/django-cms/djangocms-versioning/pull/349 +* Updates for file djangocms_versioning/locale/en/LC_MESSAGES/django.po in fr on branch master by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/347 +* docs: List `DJANGOCMS_VERSIONING_LOCK_VERSIONS` in settings by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/350 +* docs: Update documentation by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/351 +* fix: Update templates for better styling w/o djangocms-admin-style by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/352 +* fix: PageContent extension's `copy_relations` method not called by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/344 +* Bugfix/use keyword arguments in admin render change form method by @vipulnarang95 in https://github.com/django-cms/djangocms-versioning/pull/356 +* Provide additional information when sending publish/unpublish events by @GaretJax in https://github.com/django-cms/djangocms-versioning/pull/348 +* fix: Preview link language by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/357 +* docs: Document version states by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/362 +* feat: Add configuration to manage redirect on publish by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/358 + +New Contributors +---------------- +* @marksweb made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/271 +* @fsbraun made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/287 +* @svandeneertwegh made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/310 +* @stefanw made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/346 +* @FinalAngel made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/349 +* @vipulnarang95 made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/356 +* @GaretJax made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/348 1.2.2 (2022-07-20) ================== diff --git a/README.rst b/README.rst index e4c7bb1e..1556a22e 100644 --- a/README.rst +++ b/README.rst @@ -32,9 +32,11 @@ Add ``djangocms_versioning`` to your project's ``INSTALLED_APPS``. Run:: - python manage.py migrate djangocms_versioning + python -m manage migrate djangocms_versioning + python -m manage create_versions --user-id -to perform the application's database migrations. +to perform the application's database migrations and (only if you have an existing database) add version objects +needed to mark existing versions as draft. ===== diff --git a/djangocms_versioning/__init__.py b/djangocms_versioning/__init__.py index 0cda2d10..9aa3f903 100644 --- a/djangocms_versioning/__init__.py +++ b/djangocms_versioning/__init__.py @@ -1 +1 @@ -__version__ = "2.0.0rc1" +__version__ = "2.1.0" diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index f3526ce3..3998dd72 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -8,6 +8,7 @@ from cms.models import PageContent from cms.utils import get_language_from_request from cms.utils.conf import get_cms_setting +from cms.utils.helpers import is_editable_model from cms.utils.urlutils import add_url_parameters, static_with_version from django.conf import settings from django.contrib import admin, messages @@ -61,9 +62,14 @@ class VersioningChangeListMixin: """Mixin used for ChangeList classes of content models.""" - def get_queryset(self, request): + def get_queryset(self, request, exclude_parameters=None): """Limit the content model queryset to the latest versions only.""" - queryset = super().get_queryset(request) + if exclude_parameters: + # Django 5.0+ (facet support) + queryset = super().get_queryset(request, exclude_parameters) + else: + # Django 4.2 compatible get_queryset + queryset = super().get_queryset(request) versionable = versionables.for_content(queryset.model) """Check if there is a method "self.get__from_request" for each extra grouping field. @@ -134,7 +140,7 @@ def render_change_form( "versioning_fallback_change_form_template" ] = super().change_form_template - return super().render_change_form(request, context, add, change, form_url, obj) + return super().render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj) def has_change_permission(self, request, obj=None): # Add additional version checks @@ -465,10 +471,26 @@ def _get_edit_link(self, obj, request, disabled=False): f"admin:{version._meta.app_label}_{version._meta.model_name}_edit_redirect", args=(version.pk,), ) + # Only show if no draft exists + if version.state == PUBLISHED: + pks_for_grouper = version.versionable.for_content_grouping_values( + obj + ).values_list("pk", flat=True) + drafts = Version.objects.filter( + object_id__in=pks_for_grouper, + content_type=version.content_type, + state=DRAFT, + ) + if drafts.exists(): + return "" + icon = "edit-new" + else: + icon = "edit" + return self.admin_action_button( url, - icon="pencil", - title=_("Edit"), + icon=icon, + title=_("Edit") if icon == "edit" else _("New Draft"), name="edit", disabled=disabled, action="post", @@ -492,7 +514,7 @@ def get_actions_list(self): actions = [ self._get_preview_link, self._get_edit_link, - ] + ] if "state_indicator" not in self.versioning_list_display: # State indicator mixin loaded? actions.append(self._get_manage_versions_link) @@ -541,7 +563,7 @@ def get_grouping_field_filters(self, request): if value is not None: yield field, value - def get_queryset(self, request): + def get_queryset(self, request, exclude_parameters=None): """Adds support for querying the version model by grouping fields. Filters by the value of grouping fields (specified in VersionableItem @@ -551,7 +573,12 @@ def get_queryset(self, request): for specifying filters that work without being shown in the UI along with filter choices. """ - queryset = super().get_queryset(request) + if exclude_parameters: + # Django 5.0+ (facet support) + queryset = super().get_queryset(request, exclude_parameters) + else: + # Django 4.2 compatible get_queryset + queryset = super().get_queryset(request) content_model = self.model_admin.model._source_model versionable = versionables.for_content(content_model) filters = dict(self.get_grouping_field_filters(request)) @@ -609,6 +636,9 @@ class VersionAdmin(ChangeListActionsMixin, admin.ModelAdmin, metaclass=MediaDefi # def get_queryset(self, request): # return super().get_queryset(request).prefetch_related('content') + class Media: + js = ["djangocms_versioning/js/versioning.js"] + def get_changelist(self, request, **kwargs): return VersionChangeList @@ -683,13 +713,13 @@ def _get_archive_link(self, obj, request, disabled=False): icon="archive", title=_("Archive"), name="archive", - disabled=not obj.can_be_archived(), + disabled=not obj.check_archive.as_bool(request.user), ) def _get_publish_link(self, obj, request): """Helper function to get the html link to the publish action """ - if not obj.check_publish.as_bool(request.user): + if not obj.can_be_published(): # Don't display the link if it can't be published return "" publish_url = reverse( @@ -702,14 +732,14 @@ def _get_publish_link(self, obj, request): title=_("Publish"), name="publish", action="post", - disabled=not obj.can_be_published(), + disabled=not obj.check_publish.as_bool(request.user), keepsideframe=False, ) def _get_unpublish_link(self, obj, request, disabled=False): """Helper function to get the html link to the unpublish action """ - if not obj.check_unpublish.as_bool(request.user): + if not obj.can_be_unpublished(): # Don't display the link if it can't be unpublished return "" unpublish_url = reverse( @@ -721,12 +751,13 @@ def _get_unpublish_link(self, obj, request, disabled=False): icon="unpublish", title=_("Unpublish"), name="unpublish", - disabled=not obj.can_be_unpublished(), + disabled=not obj.check_unpublish.as_bool(request.user), ) def _get_edit_link(self, obj, request, disabled=False): """Helper function to get the html link to the edit action """ + if not obj.check_edit_redirect.as_bool(request.user): return "" @@ -744,7 +775,7 @@ def _get_edit_link(self, obj, request, disabled=False): return "" icon = "edit-new" else: - icon = "pencil" + icon = "edit" # Don't open in the sideframe if the item is not sideframe compatible keepsideframe = obj.versionable.content_model_is_sideframe_editable @@ -756,7 +787,7 @@ def _get_edit_link(self, obj, request, disabled=False): return self.admin_action_button( edit_url, icon=icon, - title=_("Edit") if icon == "pencil" else _("New Draft"), + title=_("Edit") if icon == "edit" else _("New Draft"), name="edit", action="post", disabled=disabled, @@ -766,7 +797,7 @@ def _get_edit_link(self, obj, request, disabled=False): def _get_revert_link(self, obj, request, disabled=False): """Helper function to get the html link to the revert action """ - if not obj.check_revert.as_bool(request.user): + if obj.state in (PUBLISHED, DRAFT): # Don't display the link if it's a draft or published return "" @@ -779,13 +810,13 @@ def _get_revert_link(self, obj, request, disabled=False): icon="undo", title=_("Revert"), name="revert", - disabled=disabled, + disabled=not obj.check_revert.as_bool(request.user) or disabled, ) def _get_discard_link(self, obj, request, disabled=False): """Helper function to get the html link to the discard action """ - if not obj.check_discard.as_bool(request.user): + if obj.state != DRAFT: # Don't display the link if it's not a draft return "" @@ -798,7 +829,7 @@ def _get_discard_link(self, obj, request, disabled=False): icon="bin", title=_("Discard"), name="discard", - disabled=disabled, + disabled=not obj.check_discard.as_bool(request.user) or disabled, ) def _get_unlock_link(self, obj, request): @@ -809,12 +840,6 @@ def _get_unlock_link(self, obj, request): if not conf.LOCK_VERSIONS or obj.state != DRAFT or not version_is_locked(obj): return "" - disabled = True - # Check whether the lock can be removed - # Check that the user has unlock permission - if request.user.has_perm("djangocms_versioning.delete_versionlock"): - disabled = False - unlock_url = reverse(f"admin:{obj._meta.app_label}_{self.model._meta.model_name}_unlock", args=(obj.pk,)) return self.admin_action_button( unlock_url, @@ -822,7 +847,33 @@ def _get_unlock_link(self, obj, request): title=_("Unlock"), name="unlock", action="post", - disabled=disabled, + disabled=not obj.check_unlock.as_bool(request.user), + ) + + def _get_settings_link(self, obj, request): + """ + Generate a settings button for the Versioning Admin + """ + + # If the content object is not registered for frontend editing no action should be present + # Also, the content object must be registered with the admin site + content_model = obj.versionable.content_model + if not is_editable_model(content_model): + return "" + + try: + settings_url = reverse( + f"admin:{content_model._meta.app_label}_{content_model._meta.model_name}_change", + args=(obj.content.pk,) + ) + except Resolver404: + return "" + + return self.admin_action_button( + settings_url, + icon="settings", + title=_("Settings"), + name="settings", ) def get_actions_list(self): @@ -851,6 +902,7 @@ def get_state_actions(self): self._get_revert_link, self._get_discard_link, self._get_unlock_link, + self._get_settings_link, ] @admin.action( @@ -962,21 +1014,33 @@ def publish_view(self, request, object_id): request, self.model._meta, object_id ) + requested_redirect = request.GET.get("next", None) + if conf.ON_PUBLISH_REDIRECT in ("preview", "published"): + redirect_url=get_preview_url(version.content) + else: + redirect_url=version_list_url(version.content) + if not version.can_be_published(): self.message_user(request, _("Version cannot be published"), messages.ERROR) - return redirect(version_list_url(version.content)) + return redirect(requested_redirect or redirect_url) try: version.check_publish(request.user) except ConditionFailed as e: self.message_user(request, force_str(e), messages.ERROR) - return redirect(version_list_url(version.content)) + return redirect(requested_redirect or redirect_url) # Publish the version version.publish(request.user) + # Display message self.message_user(request, _("Version published")) - # Redirect - return redirect(version_list_url(version.content)) + + # Redirect to published? + if conf.ON_PUBLISH_REDIRECT == "published": + if hasattr(version.content, "get_absolute_url"): + redirect_url = version.content.get_absolute_url() or redirect_url + + return redirect(requested_redirect or redirect_url) def unpublish_view(self, request, object_id): """Unpublishes the specified version and redirects back to the @@ -989,16 +1053,21 @@ def unpublish_view(self, request, object_id): request, self.model._meta, object_id ) + if conf.ON_PUBLISH_REDIRECT in ("preview", "published"): + redirect_url=get_preview_url(version.content) + else: + redirect_url=version_list_url(version.content) + if not version.can_be_unpublished(): self.message_user( request, _("Version cannot be unpublished"), messages.ERROR ) - return redirect(version_list_url(version.content)) + return redirect(redirect_url) try: version.check_unpublish(request.user) except ConditionFailed as e: self.message_user(request, force_str(e), messages.ERROR) - return redirect(version_list_url(version.content)) + return redirect(redirect_url) if request.method != "POST": context = { @@ -1031,7 +1100,7 @@ def unpublish_view(self, request, object_id): # Display message self.message_user(request, _("Version unpublished")) # Redirect - return redirect(version_list_url(version.content)) + return redirect(redirect_url) def _get_edit_redirect_version(self, request, version): """Helper method to get the latest draft or create one if one does not exist.""" @@ -1086,7 +1155,7 @@ def edit_redirect_view(self, request, object_id): return redirect(version_list_url(version.content)) # Redirect - return redirect(get_editable_url(target.content)) + return redirect(get_editable_url(target.content, request.GET.get("force_admin"))) def revert_view(self, request, object_id): """Reverts to the specified version i.e. creates a draft from it.""" @@ -1192,13 +1261,8 @@ def compare_view(self, request, object_id): get_cms_setting("CMS_TOOLBAR_URL__DISABLE"): 1, get_cms_setting("CMS_TOOLBAR_URL__PERSIST"): 0, } - v1_preview_url = add_url_parameters( - reverse( - "admin:cms_placeholder_render_object_preview", - args=(v1.content_type_id, v1.object_id), - ), - **persist_params - ) + v1_preview_url = get_preview_url(v1.content) + v1_preview_url = add_url_parameters(v1_preview_url, **persist_params) # Get the list of versions for the grouper. This is for use # in the dropdown to choose a version. version_list = Version.objects.filter_by_content_grouping_values( @@ -1221,16 +1285,11 @@ def compare_view(self, request, object_id): request, self.model._meta, request.GET["compare_to"] ) else: + v2_preview_url = get_preview_url(v2.content) context.update( { "v2": v2, - "v2_preview_url": add_url_parameters( - reverse( - "admin:cms_placeholder_render_object_preview", - args=(v2.content_type_id, v2.object_id), - ), - **persist_params - ), + "v2_preview_url": add_url_parameters(v2_preview_url, **persist_params), } ) return TemplateResponse( @@ -1322,10 +1381,7 @@ def changelist_view(self, request, extra_context=None): # Check if custom breadcrumb template defined, otherwise # fallback on default breadcrumb_templates = [ - "admin/djangocms_versioning/{app_label}/{model_name}/versioning_breadcrumbs.html".format( - app_label=breadcrumb_opts.app_label, - model_name=breadcrumb_opts.model_name, - ), + f"admin/djangocms_versioning/{breadcrumb_opts.app_label}/{breadcrumb_opts.model_name}/versioning_breadcrumbs.html", "admin/djangocms_versioning/versioning_breadcrumbs.html", ] extra_context["breadcrumb_template"] = select_template(breadcrumb_templates) diff --git a/djangocms_versioning/apps.py b/djangocms_versioning/apps.py index c1ebdcfd..a5019d4c 100644 --- a/djangocms_versioning/apps.py +++ b/djangocms_versioning/apps.py @@ -12,15 +12,18 @@ def ready(self): from cms.models import contentmodels, fields from cms.signals import post_obj_operation, post_placeholder_operation + from .conf import LOCK_VERSIONS from .handlers import ( update_modified_date, update_modified_date_for_pagecontent, update_modified_date_for_placeholder_source, ) - from .helpers import is_content_editable + from .helpers import is_content_editable, placeholder_content_is_unlocked_for_user # Add check to PlaceholderRelationField fields.PlaceholderRelationField.default_checks += [is_content_editable] + if LOCK_VERSIONS: + fields.PlaceholderRelationField.default_checks += [placeholder_content_is_unlocked_for_user] # Remove uniqueness constraint from PageContent model to allow for different versions pagecontent_unique_together = tuple( diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index bd113ccd..ae864f2b 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -3,7 +3,6 @@ from cms.app_base import CMSAppConfig, CMSAppExtension from cms.extensions.models import BaseExtension from cms.models import PageContent, Placeholder -from cms.utils import get_language_from_request from cms.utils.i18n import get_language_list, get_language_tuple from cms.utils.plugins import copy_plugins_to_placeholder from cms.utils.urlutils import admin_reverse @@ -14,6 +13,7 @@ ObjectDoesNotExist, PermissionDenied, ) +from django.db.models import Prefetch from django.http import ( HttpResponse, HttpResponseBadRequest, @@ -23,16 +23,15 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from . import indicators, versionables +from . import indicators from .admin import VersioningAdminMixin -from .conf import LOCK_VERSIONS from .constants import INDICATOR_DESCRIPTIONS from .datastructures import BaseVersionableItem, VersionableItem from .exceptions import ConditionFailed from .helpers import ( get_latest_admin_viewable_content, inject_generic_relation_to_version, - placeholder_content_is_unlocked_for_user, + is_editable, register_versionadmin_proxy, replace_admin_for_models, replace_manager, @@ -159,12 +158,6 @@ def handle_admin_field_modifiers(self, cms_config): for key in modifier.keys(): self.add_to_field_extension[key] = modifier[key] - def handle_locking(self): - if LOCK_VERSIONS: - from cms.models import fields - - fields.PlaceholderRelationField.default_checks += [placeholder_content_is_unlocked_for_user] - def configure_app(self, cms_config): if hasattr(cms_config, "extended_admin_field_modifiers"): self.handle_admin_field_modifiers(cms_config) @@ -187,7 +180,6 @@ def configure_app(self, cms_config): self.handle_version_admin(cms_config) self.handle_content_model_generic_relation(cms_config) self.handle_content_model_manager(cms_config) - self.handle_locking() def copy_page_content(original_content): @@ -276,6 +268,8 @@ def on_page_content_archive(version): class VersioningCMSPageAdminMixin(VersioningAdminMixin): + change_form_template = "admin/djangocms_versioning/page/change_form.html" + def get_readonly_fields(self, request, obj=None): fields = super().get_readonly_fields(request, obj) if obj: @@ -289,28 +283,9 @@ def get_readonly_fields(self, request, obj=None): fields.remove(f_name) return fields - def get_form(self, request, obj=None, **kwargs): - form = super().get_form(request, obj, **kwargs) - if obj: - version = Version.objects.get_for_content(obj) - if not version.check_modify.as_bool(request.user): - for f_name in ["slug", "overwrite_url"]: - form.declared_fields[f_name].widget.attrs["readonly"] = True - return form - def get_queryset(self, request): - urls = ("cms_pagecontent_get_tree",) - queryset = super().get_queryset(request) - if request.resolver_match.url_name in urls: - versionable = versionables.for_content(queryset.model) - - # TODO: Improve the grouping filters to use anything defined in the - # apps versioning config extra_grouping_fields - grouping_filters = {} - if "language" in versionable.extra_grouping_fields: - grouping_filters["language"] = get_language_from_request(request) - - return queryset.filter(pk__in=versionable.distinct_groupers(**grouping_filters)) + queryset = super().get_queryset(request)\ + .prefetch_related(Prefetch("versions", to_attr="prefetched_versions")) return queryset # CAVEAT: @@ -376,7 +351,18 @@ def get_indicator_menu(cls, request, page_content): """Get the indicator menu for PageContent object taking into account the currently available versions""" menu_template = "admin/cms/page/tree/indicator_menu.html" - status = page_content.content_indicator() + if hasattr(page_content.page, "filtered_translations") and hasattr(page_content, "prefetched_versions"): + # get_tree has prefetched versions + versions = sorted( + [content.prefetched_versions[0] for content in page_content.page.filtered_translations], + key=lambda version: -version.pk, + ) + for content in page_content.page.filtered_translations: + content.__dict__["content"] = content + status = page_content.content_indicator(versions) + else: + # No prefetched versions available, get them ourselves + status = page_content.content_indicator() if not status or status == "empty": # pragma: no cover return super().get_indicator_menu(request, page_content) versions = page_content._version # Cache from .content_indicator() @@ -408,5 +394,5 @@ class VersioningCMSConfig(CMSAppConfig): ) ] cms_toolbar_mixin = CMSToolbarVersioningMixin - PageContent.add_to_class("is_editable", indicators.is_editable) + PageContent.add_to_class("is_editable", is_editable) PageContent.add_to_class("content_indicator", indicators.content_indicator) diff --git a/djangocms_versioning/cms_menus.py b/djangocms_versioning/cms_menus.py index 7d955384..51fd54ec 100644 --- a/djangocms_versioning/cms_menus.py +++ b/djangocms_versioning/cms_menus.py @@ -2,6 +2,11 @@ from cms.apphook_pool import apphook_pool from cms.cms_menus import CMSMenu as OriginalCMSMenu, get_visible_nodes from cms.models import Page + +try: + from cms.models import TreeNode +except ImportError: + TreeNode = None from cms.toolbar.utils import get_object_preview_url, get_toolbar_from_request from cms.utils.page import get_page_queryset from django.apps import apps @@ -76,6 +81,11 @@ def _get_attrs_for_node(renderer, page_content): class CMSMenu(Menu): + """This is a legacy class used by django CMS 4.0 and django CMS 4.1.0 only. Its language + fallback mechanism does not comply with django CMS' core's. Also, it is by far slower + than django CMS core's. As of django CMS 4.1.1, this class is by default deactivated. + + See https://discord.com/channels/800813886689247262/1204047551570120755 for more information.""" def get_nodes(self, request): site = self.renderer.site language = self.renderer.request_language @@ -94,7 +104,7 @@ def get_nodes(self, request): # Depending on the toolbar mode, we need to get the correct version. # On edit or preview mode: return DRAFT, - # if DRAFT does not exists then return PUBLISHED. + # if DRAFT does not exist then return PUBLISHED. # On public mode: return PUBLISHED. if edit_or_preview: states = [constants.DRAFT, constants.PUBLISHED] @@ -106,8 +116,8 @@ def get_nodes(self, request): versionable_item.content_model._base_manager.filter( language=language, page__in=pages_qs, versions__state__in=states ) - .order_by("page__node__path", "versions__state") - .select_related("page", "page__node") + .order_by("page__node__path" if TreeNode else "page__path", "versions__state") + .select_related("page", "page__node" if TreeNode else "page") .prefetch_related("versions") ) added_pages = [] @@ -117,7 +127,7 @@ def get_nodes(self, request): if page not in visible_pages_for_user: # The page is restricted for the user. - # Therefore we avoid adding it to the menu. + # Therefore, we avoid adding it to the menu. continue version = page_content.versions.all()[0] diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index d4fed366..fc635d00 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -1,5 +1,6 @@ from collections import OrderedDict from copy import copy +from typing import Optional from cms.cms_toolbars import ( ADD_PAGE_LANGUAGE_BREAK, @@ -35,9 +36,6 @@ class VersioningToolbar(PlaceholderToolbar): - class Media: - js = ("cms/js/admin/actions.js",) - def _get_versionable(self): """Helper method to get the versionable for the content type of the version @@ -79,7 +77,7 @@ def _add_publish_button(self): _("Publish"), url=publish_url, disabled=False, - extra_classes=["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-publish-btn"], + extra_classes=["cms-btn-action", "cms-form-post-method", "cms-versioning-js-publish-btn"], ) self.toolbar.add_item(item) @@ -115,7 +113,7 @@ def _add_edit_button(self, disabled=False): _("Edit") if draft_exists else _("New Draft"), url=edit_url, disabled=disabled, - extra_classes=["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-edit-btn"], + extra_classes=["cms-btn-action", "cms-form-post-method", "cms-versioning-js-edit-btn"], ) self.toolbar.add_item(item) @@ -125,8 +123,8 @@ def _add_unlock_button(self): if LOCK_VERSIONS and self._is_versioned(): item = ButtonList(side=self.toolbar.RIGHT) proxy_model = self._get_proxy_model() - version = Version.objects.get_for_content(self.toolbar.obj) - if version.check_unlock.as_bool(self.request.user): + version = Version.objects.filter_by_content_grouping_values(self.toolbar.obj).filter(state=DRAFT).first() + if version and version.check_unlock.as_bool(self.request.user): unlock_url = reverse( f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_unlock", args=(version.pk,), @@ -135,7 +133,6 @@ def _add_unlock_button(self): if can_unlock: extra_classes = [ "cms-btn-action", - "js-action", "cms-form-post-method", "cms-versioning-js-unlock-btn", ] @@ -216,7 +213,7 @@ def _add_versioning_menu(self): url += "?" + urlencode({ "compare_to": version.pk, - "back": self.request.get_full_path(), + "back": self.toolbar.request_path, }) versioning_menu.add_link_item(name, url=url) # Discard changes menu entry (wrt to source) @@ -292,15 +289,31 @@ class VersioningPageToolbar(PageToolbar): Overriding the original Page toolbar to ensure that draft and published pages can be accessed and to allow full control over the Page toolbar for versioned pages. """ - def get_page_content(self, language=None): + + def __init__(self, *args, **kwargs): + self.page_content: Optional[PageContent] = None + super().__init__(*args, **kwargs) + + def get_page_content(self, language: Optional[str] = None) -> PageContent: + # This method overwrites the method in django CMS core. Not necessary + # for django CMS 4.2+ if not language: language = self.current_lang - return get_latest_admin_viewable_content(self.page, language=language) + if isinstance(self.page_content, PageContent) and self.page_content.language == language: + # Already known - no need to query it again + return self.page_content + toolbar_obj = self.toolbar.get_object() + if isinstance(toolbar_obj, PageContent) and toolbar_obj.language == language: + # Already in the toolbar, then use it! + return toolbar_obj + else: + # Get it from the DB + return get_latest_admin_viewable_content(self.page, language=language) def populate(self): - self.page = self.request.current_page or getattr(self.toolbar.obj, "page", None) - self.title = self.get_page_content() if self.page else None + self.page = self.request.current_page + self.page_content = self.get_page_content() if self.page else None self.permissions_activated = get_cms_setting("PERMISSION") self.override_language_menu() @@ -316,7 +329,7 @@ def override_language_menu(self): # Only override the menu if it exists and a page can be found language_menu = self.toolbar.get_menu(LANGUAGE_MENU_IDENTIFIER, _("Language")) if settings.USE_I18N and language_menu and self.page: - # remove_item uses `items` attribute so we have to copy object + # remove_item uses `items` attribute, so we have to copy object for _item in copy(language_menu.items): language_menu.remove_item(item=_item) diff --git a/djangocms_versioning/conditions.py b/djangocms_versioning/conditions.py index c73a8c14..fd76a007 100644 --- a/djangocms_versioning/conditions.py +++ b/djangocms_versioning/conditions.py @@ -76,3 +76,22 @@ def inner(version, user): else: raise ConditionFailed(message) return inner + +def user_can_unlock(message: str) -> callable: + def inner(version, user): + if not user.has_perm("djangocms_versioning.delete_versionlock"): + raise ConditionFailed(message) + return inner + +def user_can_publish(message: str) -> callable: + def inner(version, user): + if not version.has_publish_permission(user): + raise ConditionFailed(message) + return inner + + +def user_can_change(message: str) -> callable: + def inner(version, user): + if not version.has_change_permission(user): + raise ConditionFailed(message) + return inner diff --git a/djangocms_versioning/conf.py b/djangocms_versioning/conf.py index 67634898..1188780e 100644 --- a/djangocms_versioning/conf.py +++ b/djangocms_versioning/conf.py @@ -1,7 +1,8 @@ +from cms import __version__ as CMS_VERSION from django.conf import settings ENABLE_MENU_REGISTRATION = getattr( - settings, "DJANGOCMS_VERSIONING_ENABLE_MENU_REGISTRATION", True + settings, "DJANGOCMS_VERSIONING_ENABLE_MENU_REGISTRATION", CMS_VERSION <= "4.1.0" ) USERNAME_FIELD = getattr( @@ -27,3 +28,8 @@ EMAIL_NOTIFICATIONS_FAIL_SILENTLY = getattr( settings, "EMAIL_NOTIFICATIONS_FAIL_SILENTLY", False ) + +ON_PUBLISH_REDIRECT = getattr( + settings, "DJANGOCMS_VERISONING_ON_PUBLISH_REDIRECT", "published" +) +#: Allowed values: "versions", "published", "preview" diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index e58770af..9afa7140 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -15,6 +15,7 @@ from django.db import models from django.template.loader import render_to_string from django.utils.encoding import force_str +from django.utils.translation import get_language from . import versionables from .conf import EMAIL_NOTIFICATIONS_FAIL_SILENTLY @@ -26,6 +27,13 @@ emit_content_change = None +def is_editable(content_obj, request): + """Check of content_obj is editable""" + from .models import Version + + return Version.objects.get_for_content(content_obj).check_modify.as_bool(request.user) + + def versioning_admin_factory(admin_class, mixin): """A class factory returning admin class with overriden versioning functionality. @@ -147,6 +155,8 @@ def inject_generic_relation_to_version(model): related_query_name = f"{model._meta.app_label}_{model._meta.model_name}" model.add_to_class("versions", GenericRelation( Version, related_query_name=related_query_name)) + if not hasattr(model, "is_editable"): + model.add_to_class("is_editable", is_editable) def _set_default_manager(model, manager): @@ -223,11 +233,11 @@ def is_content_editable(placeholder, user): return version.state == DRAFT -def get_editable_url(content_obj): +def get_editable_url(content_obj, force_admin=False): """If the object is editable the cms editable view should be used, with the toolbar. - This method is provides the URL for it. + This method provides the URL for it. """ - if is_editable_model(content_obj.__class__): + if is_editable_model(content_obj.__class__) and not force_admin: language = getattr(content_obj, "language", None) url = get_object_edit_url(content_obj, language) # Or else, the standard edit view should be used @@ -263,9 +273,12 @@ def get_preview_url(content_obj: models.Model, language: typing.Union[str, None] if versionable.preview_url: return versionable.preview_url(content_obj) if is_editable_model(content_obj.__class__): + if not language: + # Use language field is content object has one to determine the language + language = getattr(content_obj, "language", get_language()) url = get_object_preview_url(content_obj, language=language) - # Or else, the standard change view should be used else: + # Or else, the standard change view should be used url = admin_reverse( f"{content_obj._meta.app_label}_{content_obj._meta.model_name}_change", args=[content_obj.pk], @@ -291,7 +304,7 @@ def remove_published_where(queryset): def get_latest_admin_viewable_content( - grouper: type, + grouper: models.Model, include_unpublished_archived: bool = False, **extra_grouping_fields, ) -> models.Model: @@ -374,7 +387,11 @@ def content_is_unlocked_for_user(content: models.Model, user: settings.AUTH_USER """Check if lock doesn't exist or object is locked to provided user. """ try: - return version_is_unlocked_for_user(content.versions.first(), user) + if hasattr(content, "prefetched_versions"): + version = content.prefetched_versions[0] + else: + version = content.versions.first() + return version_is_unlocked_for_user(version, user) except AttributeError: return True @@ -412,15 +429,16 @@ def send_email( def get_latest_draft_version(version): - """Get latest draft version of version object - """ + """Get latest draft version of version object and caches it in the + content object""" from djangocms_versioning.constants import DRAFT from djangocms_versioning.models import Version - drafts = ( - Version.objects - .filter_by_content_grouping_values(version.content) - .filter(state=DRAFT) - ) - - return drafts.first() + if not hasattr(version.content, "_latest_draft_version"): + drafts = ( + Version.objects + .filter_by_content_grouping_values(version.content) + .filter(state=DRAFT) + ) + version.content._latest_draft_version = drafts.first() + return version.content._latest_draft_version diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 2e8380fb..a23ebd13 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -1,5 +1,8 @@ +import typing + from cms.utils.urlutils import admin_reverse from django.contrib.auth import get_permission_codename +from django.db import models from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ @@ -87,45 +90,42 @@ def content_indicator_menu(request, status, versions, back=""): return menu -def content_indicator(content_obj): +def content_indicator( + content_obj: models.Model, + versions: typing.Optional[list[Version]] = None +) -> typing.Optional[str]: """Translates available versions into status to be reflected by the indicator. Function caches the result in the page_content object""" if not content_obj: return None # pragma: no cover elif not hasattr(content_obj, "_indicator_status"): - versions = Version.objects.filter_by_content_grouping_values( - content_obj - ).order_by("-pk") + if versions is None: + # Get all versions for the content object if not available + versions = Version.objects.filter_by_content_grouping_values( + content_obj + ).order_by("-pk") + version_states = dict(VERSION_STATES) signature = { - state: versions.filter(state=state) - for state, name in VERSION_STATES + version.state: version + for version in versions if version.state in version_states } - if signature[DRAFT] and not signature[PUBLISHED]: + if DRAFT in signature and PUBLISHED not in signature: content_obj._indicator_status = "draft" - content_obj._version = signature[DRAFT] - elif signature[DRAFT] and signature[PUBLISHED]: + content_obj._version = signature[DRAFT], + elif DRAFT in signature and PUBLISHED in signature: content_obj._indicator_status = "dirty" - content_obj._version = (signature[DRAFT][0], signature[PUBLISHED][0]) - elif signature[PUBLISHED]: + content_obj._version = (signature[DRAFT], signature[PUBLISHED]) + elif PUBLISHED in signature: content_obj._indicator_status = "published" - content_obj._version = signature[PUBLISHED] + content_obj._version = signature[PUBLISHED], elif versions[0].state == UNPUBLISHED: content_obj._indicator_status = "unpublished" - content_obj._version = signature[UNPUBLISHED] + content_obj._version = signature[UNPUBLISHED], elif versions[0].state == ARCHIVED: content_obj._indicator_status = "archived" - content_obj._version = signature[ARCHIVED] + content_obj._version = signature[ARCHIVED], else: # pragma: no cover content_obj._indicator_status = None content_obj._version = [None] return content_obj._indicator_status - - -def is_editable(content_obj, request): - """Check of content_obj is editable""" - if not content_obj.content_indicator(): - # Something's wrong: content indicator not identified. Maybe no version? - return False - versions = content_obj._version - return versions[0].check_modify.as_bool(request.user) diff --git a/djangocms_versioning/locale/ar/LC_MESSAGES/django.mo b/djangocms_versioning/locale/ar/LC_MESSAGES/django.mo new file mode 100644 index 00000000..15abc8cd Binary files /dev/null and b/djangocms_versioning/locale/ar/LC_MESSAGES/django.mo differ diff --git a/djangocms_versioning/locale/ar/LC_MESSAGES/django.po b/djangocms_versioning/locale/ar/LC_MESSAGES/django.po new file mode 100644 index 00000000..7de35d10 --- /dev/null +++ b/djangocms_versioning/locale/ar/LC_MESSAGES/django.po @@ -0,0 +1,502 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +# Translators: +# Seraj Adden Baltu, 2024 +# Fabian Braun , 2024 +# Mohammad Alsakhawy, 2024 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" +"PO-Revision-Date: 2023-01-10 15:29+0000\n" +"Last-Translator: Mohammad Alsakhawy, 2024\n" +"Language-Team: Arabic (https://app.transifex.com/divio/teams/58664/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#: admin.py:164 admin.py:301 admin.py:377 +msgid "State" +msgstr "الحالة" + +#: admin.py:192 constants.py:27 +msgid "Empty" +msgstr "فارغ" + +#: admin.py:315 admin.py:387 +msgid "Author" +msgstr "المؤلف" + +#: admin.py:329 admin.py:401 models.py:87 +msgid "Modified" +msgstr "تعديل" + +#: admin.py:437 admin.py:667 +#: templates/djangocms_versioning/admin/icons/preview.html:3 +#: templates/djangocms_versioning/admin/preview.html:3 +msgid "Preview" +msgstr "معاينة" + +#: admin.py:470 admin.py:758 cms_toolbars.py:115 +#: templates/djangocms_versioning/admin/icons/edit_icon.html:3 +msgid "Edit" +msgstr "تحرير" + +#: admin.py:482 +#: templates/djangocms_versioning/admin/icons/manage_versions.html:3 +msgid "Manage versions" +msgstr "إدارة الإصدارات " + +#: admin.py:631 +msgid "Content" +msgstr "المحتوى" + +#: admin.py:647 +msgid "locked" +msgstr "مقفول" + +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +msgid "Archive" +msgstr "أرشيف" + +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 +#: templates/djangocms_versioning/admin/icons/publish_icon.html:3 +msgid "Publish" +msgstr "نشر" + +#: admin.py:721 indicators.py:54 indicators.py:60 +#: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 +msgid "Unpublish" +msgstr "الغاء النشر " + +#: admin.py:758 cms_toolbars.py:115 +msgid "New Draft" +msgstr "مسودة جديدة " + +#: admin.py:779 cms_toolbars.py:177 +#: templates/djangocms_versioning/admin/icons/revert_icon.html:3 +msgid "Revert" +msgstr "استرجاع" + +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +msgid "Discard" +msgstr "تجاهل" + +#: admin.py:821 cms_toolbars.py:145 +msgid "Unlock" +msgstr "إلغاء القفل " + +#: admin.py:856 +msgid "Compare versions" +msgstr "مقارنة الإصدارات " + +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "يجب تحديد إثنين من الإصدارات بالضبط" + +#: admin.py:903 +msgid "Version cannot be archived" +msgstr "لا يمكن أرشفة الإصدار " + +#: admin.py:929 +msgid "Version archived" +msgstr "تمت أرشفة الإصدار " + +#: admin.py:940 admin.py:1059 admin.py:1235 +msgid "This view only supports POST method." +msgstr "هذا العرض يدعم فقط طريقة POST" + +#: admin.py:951 +msgid "Version cannot be published" +msgstr "لا يمكن نشر الإصدار " + +#: admin.py:962 +msgid "Version published" +msgstr "تم نشر الإصدار " + +#: admin.py:979 +msgid "Version cannot be unpublished" +msgstr "لا يمكن إلغاء نشر الإصدار" + +#: admin.py:1017 +msgid "Version unpublished" +msgstr "تم إلغاء نشر الإصدار" + +#: admin.py:1163 +msgid "The last version has been deleted" +msgstr "تم حذف الإصدار السابق" + +#: admin.py:1249 +msgid "You do not have permission to remove the version lock" +msgstr "ليس لديك صلاحيات لحذف قفل الإصدار" + +#: admin.py:1254 +msgid "Version unlocked" +msgstr "تم إلغاء قفل الإصدار" + +#: admin.py:1303 +#, python-brace-format +msgid "Displaying versions of \"{grouper}\"" +msgstr "عرض إصدارات \"{grouper}\"" + +#: apps.py:8 +msgid "django CMS Versioning" +msgstr "إصدارات ن.إ.م. چانجو" + +#: cms_config.py:246 +msgid "No available title" +msgstr "بدون عنوان" + +#: cms_config.py:248 constants.py:12 constants.py:25 +msgid "Unpublished" +msgstr "غير منشور" + +#: cms_config.py:342 +msgid "Language must be set to a supported language!" +msgstr "يجب تحديد لغة ضمن اللغات المدعومة!" + +#: cms_config.py:360 +msgid "You do not have permission to copy these plugins." +msgstr "ليس لديك صلاحيات لنسخ هذه الملحقات." + +#: cms_toolbars.py:207 +msgid "Manage Versions" +msgstr "إدارة الإصدارات" + +#: cms_toolbars.py:210 +#, python-brace-format +msgid "Compare to {source}" +msgstr "قارن ب {source}" + +#: cms_toolbars.py:226 indicators.py:66 +msgid "Discard Changes" +msgstr " تجاهل التغييرات" + +#: cms_toolbars.py:262 +msgid "View Published" +msgstr "عرض المنشور" + +#: cms_toolbars.py:317 +msgid "Language" +msgstr "اللغة" + +#: cms_toolbars.py:364 +msgid "Add Translation" +msgstr "إضافة ترجمة" + +#: cms_toolbars.py:377 +msgid "Copy all plugins" +msgstr "نسخ كل الملحقات" + +#: cms_toolbars.py:379 +#, python-format +msgid "from %s" +msgstr "من %s" + +#: cms_toolbars.py:380 +#, python-format +msgid "Are you sure you want to copy all plugins from %s?" +msgstr "هل أنت متأكد من أنك تريد نسخ كل الملحقات من %s؟" + +#: cms_toolbars.py:395 +msgid "No other language available" +msgstr "لا توجد لغة أخرى متوفرة" + +#: constants.py:10 constants.py:24 +msgid "Draft" +msgstr "مسودة" + +#: constants.py:11 constants.py:22 +msgid "Published" +msgstr "منشور" + +#: constants.py:13 constants.py:26 +msgid "Archived" +msgstr "مُؤرشَف" + +#: constants.py:23 +msgid "Changed" +msgstr "مُعدّل" + +#: emails.py:39 +msgid "Unlocked" +msgstr "أُلغيَ القفل" + +#: indicators.py:28 +#, python-format +msgid "Unlock (%(message)s)" +msgstr "إلغاء قفل (%(message)s)" + +#: indicators.py:40 +msgid "Create new draft" +msgstr "إنشاء مسودة جديدة" + +#: indicators.py:46 +msgid "Revert from Unpublish" +msgstr "تراجع عن إلغاء النشر" + +#: indicators.py:66 +msgid "Delete Draft" +msgstr "حذف المسودة" + +#: indicators.py:72 +msgid "Compare Draft to Published..." +msgstr "قارن المسودة بالمنشور..." + +#: indicators.py:82 +msgid "Manage Versions..." +msgstr "إدارة الإصدارات..." + +#: models.py:29 +msgid "Version is not a draft" +msgstr "الإصدار ليس مسودة" + +#: models.py:30 +#, python-brace-format +msgid "Action Denied. The latest version is locked by {user}" +msgstr "تم رفض الإجراء. أحدث إصدار مقفول بواسطة {user}" + +#: models.py:31 +#, python-brace-format +msgid "Action Denied. The draft version is locked by {user}" +msgstr "تم رفض الإجراء. إصدار المسودة مقفول بواسطة {user}" + +#: models.py:86 +msgid "Created" +msgstr "أُنشئ" + +#: models.py:89 +msgid "author" +msgstr "المؤلف" + +#: models.py:102 +msgid "status" +msgstr "الحالة" + +#: models.py:110 +msgid "locked by" +msgstr "مقفول بواسطة" + +#: models.py:119 +msgid "source" +msgstr "المصدر" + +#: models.py:133 +#, python-brace-format +msgid "Version #{number} ({state} {date})" +msgstr "الإصدار #{number} ({state} {date})" + +#: models.py:140 +#, python-brace-format +msgid "Version #{number} ({state})" +msgstr "الإصدار #{number} ({state})" + +#: models.py:146 +#, python-format +msgid "Locked by %(user)s" +msgstr "مقفول بواسطة %(user)s" + +#: models.py:278 models.py:327 +msgid "Version is not in draft state" +msgstr "الإصدار ليس في حالة مسودة" + +#: models.py:387 +msgid "Version is not in published state" +msgstr "الإصدار ليس في حالة منشور" + +#: models.py:444 +msgid "Version is not in archived or unpublished state" +msgstr "الإصدار ليس في حالة أرشفة أو حالة غير منشور" + +#: models.py:459 +msgid "Version is not in draft or published state" +msgstr "الإصدار ليس في حالة مسودة أو حالة منشور" + +#: models.py:467 +msgid "Version is already locked" +msgstr "تم قفل الإصدار بالفعل" + +#: models.py:473 +msgid "Draft version is not locked" +msgstr "إصدار المسودة غير مقفول" + +#: templates/admin/djangocms_versioning/versioning_breadcrumbs.html:3 +#: templates/djangocms_versioning/admin/grouper_form.html:9 +msgid "Home" +msgstr "الرئيسية" + +#: templates/admin/djangocms_versioning/versioning_breadcrumbs.html:7 +#: templates/djangocms_versioning/admin/mixin/change_form.html:7 +msgid "Versions" +msgstr "الإصدارات" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:3 +msgid "Archive Confirmation" +msgstr "تأكيد الأرشفة" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:15 +msgid "Are you sure you want to archive the following version?" +msgstr "هل أنت متأكد أنك تريد أرشفة الإصدار التالي؟" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:17 +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:17 +#, python-format +msgid " Version number: %(version_number)s" +msgstr "إصدار رقم: %(version_number)s" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:22 +#: templates/djangocms_versioning/admin/discard_confirmation.html:23 +#: templates/djangocms_versioning/admin/revert_confirmation.html:40 +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:27 +msgid "Yes, I'm sure" +msgstr "نعم، أنا متأكد" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:26 +#: templates/djangocms_versioning/admin/discard_confirmation.html:27 +#: templates/djangocms_versioning/admin/revert_confirmation.html:45 +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:31 +msgid "No, take me back" +msgstr "لا، تراجع للخلف" + +#: templates/djangocms_versioning/admin/compare.html:8 +#, python-format +msgid "" +"\n" +" Compare %(left)s to %(right)s\n" +" " +msgstr "" +"\n" +"قارن %(left)s بـ %(right)s" + +#: templates/djangocms_versioning/admin/compare.html:12 +#, python-format +msgid "" +"\n" +" Compare %(left)s\n" +" " +msgstr "" +"\n" +"قارن %(left)s" + +#: templates/djangocms_versioning/admin/compare.html:16 +#, python-format +msgid "" +"\n" +" Compare %(right)s\n" +" " +msgstr "" +"\n" +"قارن %(right)s" + +#: templates/djangocms_versioning/admin/compare.html:37 +msgid "Back" +msgstr "رجوع" + +#: templates/djangocms_versioning/admin/compare.html:40 +#, python-format +msgid "" +"\n" +" Comparing %(left)s with\n" +" " +msgstr "" +"\n" +"مقارنة %(left)s بـ" + +#: templates/djangocms_versioning/admin/compare.html:45 +msgid "Pick a version to compare to" +msgstr "اختر إصدار للمقارنة" + +#: templates/djangocms_versioning/admin/compare.html:56 +msgid "Visual" +msgstr "مرئي" + +#: templates/djangocms_versioning/admin/compare.html:59 +msgid "Source" +msgstr "مصدر" + +#: templates/djangocms_versioning/admin/discard_confirmation.html:3 +msgid "Discard Confirmation" +msgstr "تأكيد التجاهل" + +#: templates/djangocms_versioning/admin/discard_confirmation.html:15 +msgid "Are you sure you want to discard following version?" +msgstr "هل أنت متأكد أنك تريد تجاهل الإصدار التالي؟" + +#: templates/djangocms_versioning/admin/discard_confirmation.html:17 +#: templates/djangocms_versioning/admin/revert_confirmation.html:24 +#, python-format +msgid "Version number: %(version_number)s" +msgstr "إصدار رقم: %(version_number)s" + +#: templates/djangocms_versioning/admin/grouper_form.html:27 +#, python-format +msgid "Add %(name)s" +msgstr "إضافة %(name)s" + +#: templates/djangocms_versioning/admin/grouper_form.html:37 +msgid "Submit" +msgstr "تأكيد" + +#: templates/djangocms_versioning/admin/icons/view.html:3 +msgid "View on site" +msgstr "عرض على الموقع" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:3 +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:3 +msgid "Revert Confirmation" +msgstr "تأكيد الاسترجاع" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:18 +msgid "" +"Reverting to this version may cause loss of an existing draft version. " +"Please select an option to continue" +msgstr "" +"الاسترجاع لهذا الإصدار قد يتسبب في خسارة إصدار مسودة متواجدة. يرجى تحديد " +"اختيار للإستمرار" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:20 +msgid "Are you sure you want to revert to the following version?" +msgstr "هل أنت متأكد أنك تريد استرجاع الإصدار التالي؟" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:31 +msgid "Discard existing draft and Revert" +msgstr "تجاهل المسودة المتواجدة واسترجع" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:35 +msgid "Archive existing draft and Revert" +msgstr "أرشف المسودة المتواجدة واسترجع" + +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:15 +msgid "" +"Unpublishing will remove this version from live. Are you sure you want to " +"unpublish?" +msgstr "" +"إلغاء النشر سيؤدي إلى حذف هذا الإصدار من مباشر الموقع. هل أنت متأكد أنك تريد" +" إلغاء النشر؟" + +#: templates/djangocms_versioning/emails/unlock-notification.txt:2 +#, python-format +msgid "" +"\n" +"The following draft version has been unlocked by %(by_user)s for their use.\n" +"%(version_link)s\n" +"\n" +"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" +"\n" +"This is an automated notification from Django CMS.\n" +msgstr "" +"\n" +"تم إلغاء قفل المسودة التالية بواسطة %(by_user)s لاستخدامهم.\n" +"%(version_link)s\n" +"\n" +"برجاء العلم أنك لن تستطيع إجراء المزيد من التعديلات على هذه المسودة. يرجى التواصل مع %(by_user)s في حالة وجود أي استفسارات.\n" +"\n" +"هذا إشعار تلقائي من ن.إ.م. چانجو.\n" diff --git a/djangocms_versioning/locale/de/LC_MESSAGES/django.mo b/djangocms_versioning/locale/de/LC_MESSAGES/django.mo index 27db87fb..b14902d4 100644 Binary files a/djangocms_versioning/locale/de/LC_MESSAGES/django.mo and b/djangocms_versioning/locale/de/LC_MESSAGES/django.mo differ diff --git a/djangocms_versioning/locale/de/LC_MESSAGES/django.po b/djangocms_versioning/locale/de/LC_MESSAGES/django.po index 939a0fc6..095da32e 100644 --- a/djangocms_versioning/locale/de/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/de/LC_MESSAGES/django.po @@ -2,124 +2,125 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# # Translators: # Fabian Braun , 2023 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Fabian Braun , 2023\n" "Language-Team: German (https://app.transifex.com/divio/teams/58664/de/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Language: de\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "Status" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "Leer" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "Autor" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "Geändert" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "Vorschau" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "Bearbeiten" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "Versionen verwalten" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "Inhalt" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "gesperrt" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "Archivieren" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "Veröffentlichen" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "Veröffentlichung aufheben" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" msgstr "Neuer Entwurf" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "Zurückholen" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "Verwerfen" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "Entsperren" #: admin.py:856 -msgid "Exactly two versions need to be selected." -msgstr "Genau zwei Versionen müssen ausgewählt werden." - -#: admin.py:870 msgid "Compare versions" msgstr "Versionen vergleichen" -#: admin.py:897 +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "Genau zwei Versionen müssen ausgewählt werden." + +#: admin.py:903 msgid "Version cannot be archived" msgstr "Version kann nicht archiviert werden" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "Version archiviert" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "Dieser View unterstützt nur die POST-Methode." -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "Version kann nicht veröffentlicht werden" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "Version veröffentlicht" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "Die Veröffentlichung kann nicht aufgehoben werden" @@ -127,19 +128,19 @@ msgstr "Die Veröffentlichung kann nicht aufgehoben werden" msgid "Version unpublished" msgstr "Veröffentlichung aufgehoben" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "Die neueste Version wurde gelöscht" -#: admin.py:1255 +#: admin.py:1249 msgid "You do not have permission to remove the version lock" msgstr "Keine Berechtigung vorhanden, um die Sperrung der Version aufzuheben." -#: admin.py:1260 +#: admin.py:1254 msgid "Version unlocked" msgstr "Version entsperrt" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "Zeige Versionen von \"{grouper}\"" @@ -148,180 +149,180 @@ msgstr "Zeige Versionen von \"{grouper}\"" msgid "django CMS Versioning" msgstr "django CMS Versioning" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "Kein Titel verfügbar" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "Veröffentlichung aufgehoben" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "Eine unterstützte Sprache muss ausgewählt sein!" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "Keine Erlaubnis, diese Plugins zu kopieren." -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "Versionen verwalten" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, python-brace-format msgid "Compare to {source}" msgstr "Mit {source} vergleichen" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" msgstr "Änderungen verwerfen" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "Veröffentlichung ansehen" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "Sprache" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "Übersetzung hinzufügen" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "Alle Plugins kopieren" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "von %s" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "Sind Sie sicher, dass sie alle Plugins von %s kopieren wollen?" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "Keine andere Sprache verfügbar" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "Entwurf" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "Veröffentlicht" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "Archiviert" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "Verändert" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "Entsperrt" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "Entsperren (%(message)s)" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "Neuen Entwurf erstellen" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "Zurückholen" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "Entwurf löschen" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "Entwurf mit Veröffentlichung vergleichen..." -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "Versionen verwalten..." -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "Version ist kein Entwurf" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "Aktion verweigert. Die aktuelle Version ist von {user} gesperrt." -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "Aktion verweigert. Der Entwurf ist von {user} gesperrt." -#: models.py:88 +#: models.py:86 msgid "Created" msgstr "Erstellt" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "Autor" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "Status" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "gesperrt von" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "Ursprung" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "Version #{number} ({state} {date}) " -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "Version #{number} ({state})" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "Gesperrt von %(user)s" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "Version ist kein Entwurf" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "Version ist nicht veröffentlicht" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "Version ist weder archiviert noch eine Veröffentlichung aufgehoben" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "Version ist weder ein Entwurf noch veröffentlicht" -#: models.py:465 +#: models.py:467 msgid "Version is already locked" msgstr "Version bereits gesperrt" -#: models.py:471 +#: models.py:473 msgid "Draft version is not locked" msgstr "Entwurf ist nicht gesperrt" diff --git a/djangocms_versioning/locale/en/LC_MESSAGES/django.po b/djangocms_versioning/locale/en/LC_MESSAGES/django.po index f5352e8b..05666e41 100644 --- a/djangocms_versioning/locale/en/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,106 +18,106 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" msgstr "" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "" #: admin.py:856 -msgid "Exactly two versions need to be selected." +msgid "Compare versions" msgstr "" -#: admin.py:870 -msgid "Compare versions" +#: admin.py:866 +msgid "Exactly two versions need to be selected." msgstr "" -#: admin.py:897 +#: admin.py:903 msgid "Version cannot be archived" msgstr "" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "" -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "" @@ -125,19 +125,19 @@ msgstr "" msgid "Version unpublished" msgstr "" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "" -#: admin.py:1255 +#: admin.py:1249 msgid "You do not have permission to remove the version lock" msgstr "" -#: admin.py:1260 +#: admin.py:1254 msgid "Version unlocked" msgstr "" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "" @@ -146,180 +146,180 @@ msgstr "" msgid "django CMS Versioning" msgstr "" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "" -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, python-brace-format msgid "Compare to {source}" msgstr "" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" msgstr "" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "" -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "" -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "" -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "" -#: models.py:88 +#: models.py:86 msgid "Created" msgstr "" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "" -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "" -#: models.py:465 +#: models.py:467 msgid "Version is already locked" msgstr "" -#: models.py:471 +#: models.py:473 msgid "Draft version is not locked" msgstr "" diff --git a/djangocms_versioning/locale/fr/LC_MESSAGES/django.mo b/djangocms_versioning/locale/fr/LC_MESSAGES/django.mo index 92cd88bb..1315279b 100644 Binary files a/djangocms_versioning/locale/fr/LC_MESSAGES/django.mo and b/djangocms_versioning/locale/fr/LC_MESSAGES/django.mo differ diff --git a/djangocms_versioning/locale/fr/LC_MESSAGES/django.po b/djangocms_versioning/locale/fr/LC_MESSAGES/django.po index 2fa5d9fa..02035cba 100644 --- a/djangocms_versioning/locale/fr/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/fr/LC_MESSAGES/django.po @@ -12,7 +12,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Frédéric Roland, 2023\n" "Language-Team: French (https://app.transifex.com/divio/teams/58664/fr/)\n" @@ -22,106 +22,106 @@ msgstr "" "Language: fr\n" "Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "État" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "Vide" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "Auteur" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "Modifié" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "Pré-visualisation" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "Éditer" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "Gérer les versions" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "Contenu" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "verrouillé" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "Archiver" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "Publier" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "Dépublier" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" msgstr "Nouveau Brouillon" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "Rétablir" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "Rejeter" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "Déverrouiller" #: admin.py:856 -msgid "Exactly two versions need to be selected." -msgstr "Il faut sélectionner exactement deux versions." - -#: admin.py:870 msgid "Compare versions" msgstr "Comparer les versions" -#: admin.py:897 +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "Il faut sélectionner exactement deux versions." + +#: admin.py:903 msgid "Version cannot be archived" msgstr "La version ne peut pas être archivée" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "Version archivée" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "Cette vue ne prend en charge que la méthode POST." -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "La version ne peut pas être publiée" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "Version publiée" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "La version ne peut pas être dépubliée" @@ -129,19 +129,19 @@ msgstr "La version ne peut pas être dépubliée" msgid "Version unpublished" msgstr "Version non publiée" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "La dernière version a été supprimée" -#: admin.py:1255 +#: admin.py:1249 msgid "You do not have permission to remove the version lock" msgstr "Vous n'avez pas la permission de retirer le verrouillage de version" -#: admin.py:1260 +#: admin.py:1254 msgid "Version unlocked" msgstr "Version déverrouillée" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "Afficher les versions de \"{grouper}\"" @@ -150,180 +150,180 @@ msgstr "Afficher les versions de \"{grouper}\"" msgid "django CMS Versioning" msgstr "django CMS Versioning" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "Aucun titre disponible" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "Non publié" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "La langue doit être définie comme une langue prise en charge !" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "Vous n'avez pas la permission de copier ces plugins." -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "Gérer les versions" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, python-brace-format msgid "Compare to {source}" msgstr "Comparer à {source}" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" msgstr "Abandonner les modifications" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "Vue publiée" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "Langue" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "Ajouter une traduction" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "Copier tous les plugins" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "de %s" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "Êtes-vous sûr de vouloir copier tous les plugins de %s?" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "Aucune autre langue disponible" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "Brouillon" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "Publié" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "Archivé" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "Modifié" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "Déverrouillée" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "Déverrouiller (%(message)s)" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "Créer un brouillon" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "Annulation de la publication" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "Supprimer le brouillon" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "Comparer le brouillon à la version publiée..." -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "Gérer les versions..." -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "La version n'est pas un brouillon" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "Action Refusée. La dernière version est verrouillée par {user}" -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "Action Refusée. Le brouillon est verrouillé par {user}" -#: models.py:88 +#: models.py:86 msgid "Created" msgstr "Créée" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "auteur" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "statut" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "verrouillée par" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "source" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "Version #{number} ({state} {date})" -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "Version #{number} ({state})" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "Verrouillée par %(user)s" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "La version n'est pas à l'état de brouillon" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "La version n'est pas dans l'état publié" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "La version n'est pas archivé ou non publié" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "La version n'est pas en brouillon ou publiée" -#: models.py:465 +#: models.py:467 msgid "Version is already locked" msgstr "La version est déjà verrouillée" -#: models.py:471 +#: models.py:473 msgid "Draft version is not locked" msgstr "Le brouillon n'est pas verrouillé" diff --git a/djangocms_versioning/locale/nl/LC_MESSAGES/django.mo b/djangocms_versioning/locale/nl/LC_MESSAGES/django.mo index a1ffd899..7bc611d5 100644 Binary files a/djangocms_versioning/locale/nl/LC_MESSAGES/django.mo and b/djangocms_versioning/locale/nl/LC_MESSAGES/django.mo differ diff --git a/djangocms_versioning/locale/nl/LC_MESSAGES/django.po b/djangocms_versioning/locale/nl/LC_MESSAGES/django.po index fec58ac3..fd9c6b65 100644 --- a/djangocms_versioning/locale/nl/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/nl/LC_MESSAGES/django.po @@ -2,126 +2,126 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# # Translators: # Fabian Braun , 2023 # Stefan van den Eertwegh , 2023 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Stefan van den Eertwegh , 2023\n" "Language-Team: Dutch (https://app.transifex.com/divio/teams/58664/nl/)\n" -"Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Language: nl\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "Status" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "Leeg" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "Auteur" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "Gewijzigd" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "Voorbeeld" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "Bewerk" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "Beheer versies" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "Content" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "gesloten" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "Archiveer" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "Publiceer" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "Gedepubliceerd" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" msgstr "Nieuw concept" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "Terugdraaien" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "Annuleer" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "Ongesloten" #: admin.py:856 -msgid "Exactly two versions need to be selected." -msgstr "Precies twee versies moeten zijn geselecteerd." - -#: admin.py:870 msgid "Compare versions" msgstr "Vergelijk versies" -#: admin.py:897 +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "Precies twee versies moeten zijn geselecteerd." + +#: admin.py:903 msgid "Version cannot be archived" msgstr "Versie kan niet worden gearchiveerd" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "Versie gearchiveerd" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "Deze weergave ondersteunt alleen de POST methode" -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "Versie kan niet worden gepubliceerd" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "Versie gepubliceerd" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "Versie kan niet worden gedepubliceerd" @@ -129,19 +129,19 @@ msgstr "Versie kan niet worden gedepubliceerd" msgid "Version unpublished" msgstr "Versie ongepubliceerd" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "De laatste versie is verwijderd" -#: admin.py:1255 +#: admin.py:1249 msgid "You do not have permission to remove the version lock" msgstr "Je hebt geen rechten om de versie van t slot te halen" -#: admin.py:1260 +#: admin.py:1254 msgid "Version unlocked" msgstr "Versie ongesloten" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "Weergave versies van \"{grouper}\"" @@ -150,180 +150,180 @@ msgstr "Weergave versies van \"{grouper}\"" msgid "django CMS Versioning" msgstr "django CMS Versionering" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "Geen beschikbare titel" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "Ongepubliceerd" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "Taal moet gespecificeerd worden binnen de ondersteunde talen!" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "Je hebt geen rechten om deze plugin te kopieëren." -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "Beheer versies" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, python-brace-format msgid "Compare to {source}" msgstr "Vergelijk met {source}" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" msgstr "Annuleer wijzigingen" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "Bekijk live versie" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "Taal" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "Voeg vertaling toe" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "Kopieer alle plugins" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "van %s" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "Ben je er zeker van om alle plugins te kopiëren van %s?" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "Geen andere taal beschikbaar" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "Concept" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "Gepubliceerd" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "Gearchiveerd" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "Gewijzigd" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "Ongesloten" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "Ongesloten (%(message)s)" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "Maakt een nieuw concept" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "Gedepubliceerde terugdraaien" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "Verwijder concept" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "Vergelijk Concept met Gepubliceerde..." -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "Beheer versies..." -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "Versie is niet een concept" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "Actie niet geldig. De laatste versie is gesloten door {user}" -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "Actie niet geldig. De concept versie is gesloten door {user}" -#: models.py:88 +#: models.py:86 msgid "Created" msgstr "Aangemaakt" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "auteur" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "status" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "gesloten door" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "bron" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "Versie #{number} ({state} {date})" -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "Versie #{number} ({state})" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "Gesloten door %(user)s" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "Versie is niet in concept staat" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "Versie is niet in gepubliceerde staat" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "Versie is niet gearchiveerd en niet gepubliceerd" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "Versie is niet een concept of gepubliceerde staat" -#: models.py:465 +#: models.py:467 msgid "Version is already locked" msgstr "Versie is al gesloten" -#: models.py:471 +#: models.py:473 msgid "Draft version is not locked" msgstr "Concept versie is niet gesloten" diff --git a/djangocms_versioning/locale/sq/LC_MESSAGES/django.mo b/djangocms_versioning/locale/sq/LC_MESSAGES/django.mo index 625df067..1929104d 100644 Binary files a/djangocms_versioning/locale/sq/LC_MESSAGES/django.mo and b/djangocms_versioning/locale/sq/LC_MESSAGES/django.mo differ diff --git a/djangocms_versioning/locale/sq/LC_MESSAGES/django.po b/djangocms_versioning/locale/sq/LC_MESSAGES/django.po index 2c740ce4..9a638674 100644 --- a/djangocms_versioning/locale/sq/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/sq/LC_MESSAGES/django.po @@ -2,127 +2,125 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# # Translators: # Besnik Bleta , 2023 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Besnik Bleta , 2023\n" -"Language-Team: Albanian (https://www.transifex.com/divio/teams/58664/sq/)\n" -"Language: sq\n" +"Language-Team: Albanian (https://app.transifex.com/divio/teams/58664/sq/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Language: sq\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "Gjendje" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "I zbrazët" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "Autor" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "E ndryshuar" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "Paraparje" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "Përpunojeni" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "Administroni versione" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "Lëndë" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "Arkiv" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "Botoje" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "Hiqe nga të botuar" -#: admin.py:760 cms_toolbars.py:121 -#, fuzzy -#| msgid "Draft" +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" -msgstr "Skicë" +msgstr "" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "Riktheje" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "Hidhe tej" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "" #: admin.py:856 -msgid "Exactly two versions need to be selected." -msgstr "Lypset të përzgjidhen saktësisht dy versione." - -#: admin.py:870 msgid "Compare versions" msgstr "Krahasoni versione" -#: admin.py:897 +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "Lypset të përzgjidhen saktësisht dy versione." + +#: admin.py:903 msgid "Version cannot be archived" msgstr "Versioni s’mund të arkivohet" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "Versioni u arkivua" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "Kjo pamje mbulon vetëm metodën POST." -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "Versioni s’mund të botohet" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "Versioni u botua" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "Versioni s’mund të shbotohet" @@ -130,23 +128,19 @@ msgstr "Versioni s’mund të shbotohet" msgid "Version unpublished" msgstr "Versioni u shbotua" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "Versioni i fundit është fshirë" -#: admin.py:1255 -#, fuzzy -#| msgid "You do not have permission to copy these plugins." +#: admin.py:1249 msgid "You do not have permission to remove the version lock" -msgstr "S’keni leje të kopjoni këto shtojca." +msgstr "" -#: admin.py:1260 -#, fuzzy -#| msgid "Version unpublished" +#: admin.py:1254 msgid "Version unlocked" -msgstr "Versioni u shbotua" +msgstr "" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "Po shfaqen versione të “{grouper}”" @@ -155,191 +149,182 @@ msgstr "Po shfaqen versione të “{grouper}”" msgid "django CMS Versioning" msgstr "Versione në django CMS" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "S’ka titull" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "I pabotuar" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "Si gjuhë duhet të caktoni një gjuhë të mbuluar!" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "S’keni leje të kopjoni këto shtojca." -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "Administroni Versione" -#: cms_toolbars.py:221 -#, fuzzy, python-brace-format -#| msgid "Compare to {state} source" +#: cms_toolbars.py:210 +#, python-brace-format msgid "Compare to {source}" -msgstr "Krahasoje me burimin {state}" +msgstr "" -#: cms_toolbars.py:236 indicators.py:73 -#, fuzzy -#| msgid "Discard" +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" -msgstr "Hidhe tej" +msgstr "" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "Shihni të Botuarin" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "Gjuhë" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "Shtoni Përkthim" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "Kopjo krejt shtojcat" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "prej %s" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "Jeni i sigurt se doni të kopjohen krejt shtojcat prej %s?" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "S’ka gjuhë të tjera" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "Skicë" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "I botuar" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "I arkivuar" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "I ndryshur" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "Krijoni një skicë të re" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "Riktheje nga Shbotoje" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "Fshije Skicën" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "Krahaso Skicë me të Pabotuar…" -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "Administroni Versione…" -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "Versioni s’është skicë" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "" -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "" -#: models.py:88 -#, fuzzy -#| msgid "Create new draft" +#: models.py:86 msgid "Created" -msgstr "Krijoni një skicë të re" +msgstr "" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "autor" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "gjendje" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "burim" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "Version #{number} ({state} {date})" -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "Version #{number} ({state})" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "Versioni s’është nën gjendjen “skicë”" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "Versioni s’është nën gjendjen “i botuar”" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "Versioni s’është nën gjendjen “i arkivuar” ose “i pabotuar”" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "Versioni s’është nën gjendjen “skicë” ose “i botuar”" -#: models.py:465 -#, fuzzy -#| msgid "Version archived" +#: models.py:467 msgid "Version is already locked" -msgstr "Versioni u arkivua" +msgstr "" -#: models.py:471 -#, fuzzy -#| msgid "Version is not a draft" +#: models.py:473 msgid "Draft version is not locked" -msgstr "Versioni s’është skicë" +msgstr "" #: templates/admin/djangocms_versioning/versioning_breadcrumbs.html:3 #: templates/djangocms_versioning/admin/grouper_form.html:9 @@ -506,17 +491,7 @@ msgid "" "The following draft version has been unlocked by %(by_user)s for their use.\n" "%(version_link)s\n" "\n" -"Please note you will not be able to further edit this draft. Kindly reach " -"out to %(by_user)s in case of any concerns.\n" +"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" "\n" "This is an automated notification from Django CMS.\n" msgstr "" - -#~ msgid "actions" -#~ msgstr "veprime" - -#~ msgid "version number" -#~ msgstr "numër versioni" - -#~ msgid "Delete Changes" -#~ msgstr "Fshiji Ndryshimet" diff --git a/djangocms_versioning/management/__init__.py b/djangocms_versioning/management/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/djangocms_versioning/management/__init__.py @@ -0,0 +1 @@ + diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index 55d615e8..5d323b76 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -60,23 +60,16 @@ def _chain(self): clone._group_by_key = self._group_by_key return clone - def current_content_iterator(self, **kwargs): - """Returns generator (not a queryset) over current content versions. Current versions are either draft - versions or published versions (in that order)""" - warnings.warn("current_content_iterator is deprecated in favour of current_conent", - DeprecationWarning, stacklevel=2) - return iter(self.current_content(**kwargs)) - def current_content(self, **kwargs): """Returns a queryset current content versions. Current versions are either draft versions or published versions (in that order). This optimized query assumes that draft versions always have a higher pk than any other version type. This is true as long as no other version type can be converted to draft without creating a new version.""" - qs = self.filter(versions__state__in=(constants.DRAFT, constants.PUBLISHED), **kwargs) - pk_filter = qs.values(*self._group_by_key)\ + pk_filter = self.filter(versions__state__in=(constants.DRAFT, constants.PUBLISHED))\ + .values(*self._group_by_key)\ .annotate(vers_pk=models.Max("versions__pk"))\ - .values_list("vers_pk", flat=True) - return qs.filter(versions__pk__in=pk_filter) + .values("vers_pk") + return self.filter(versions__pk__in=pk_filter, **kwargs) def latest_content(self, **kwargs): """Returns the "latest" content object which is in this order diff --git a/djangocms_versioning/models.py b/djangocms_versioning/models.py index b36cc5bd..08ac8079 100644 --- a/djangocms_versioning/models.py +++ b/djangocms_versioning/models.py @@ -16,6 +16,9 @@ draft_is_not_locked, in_state, is_not_locked, + user_can_change, + user_can_publish, + user_can_unlock, ) from .conf import ALLOW_DELETING_VERSIONS, LOCK_VERSIONS from .operations import send_post_version_operation, send_pre_version_operation @@ -29,7 +32,7 @@ not_draft_error = _("Version is not a draft") lock_error_message = _("Action Denied. The latest version is locked by {user}") lock_draft_error_message = _("Action Denied. The draft version is locked by {user}") - +permission_error_message = _("You do not have permission to perform this action") def allow_deleting_versions(collector, field, sub_objs, using): if ALLOW_DELETING_VERSIONS: @@ -71,8 +74,8 @@ def filter_by_grouping_values(self, versionable, **kwargs): def filter_by_content_grouping_values(self, content): """Returns a list of Version objects for grouping values taken - from provided content object. In other words: - it uses the content instance property values as filter parameters + from provided content object. In other words: + it uses the content instance property values as filter parameters """ versionable = versionables.for_content(content) content_objects = versionable.for_content_grouping_values(content) @@ -257,7 +260,7 @@ def copy(self, created_by): Allows customization of how the content object will be copied when specified in cms_config.py - This method needs to be ran in a transaction due to the fact that if + This method needs to be run in a transaction due to the fact that if models are partially created in the copy method a version is not attached. It needs to be that if anything goes wrong we should roll back the entire task. We shouldn't leave this to package developers to know to add this feature @@ -275,6 +278,7 @@ def copy(self, created_by): check_archive = Conditions( [ + user_can_change(permission_error_message), in_state([constants.DRAFT], _("Version is not in draft state")), is_not_locked(lock_error_message), ] @@ -324,7 +328,10 @@ def _set_archive(self, user): pass check_publish = Conditions( - [in_state([constants.DRAFT], _("Version is not in draft state"))] + [ + user_can_publish(permission_error_message), + in_state([constants.DRAFT], _("Version is not in draft state")), + ] ) def can_be_published(self): @@ -357,13 +364,16 @@ def publish(self, user): content_type=self.content_type, ) for version in to_unpublish: - version.unpublish(user) + version.unpublish(user, to_be_published=self) on_publish = self.versionable.on_publish if on_publish: on_publish(self) # trigger post operation signal send_post_version_operation( - constants.OPERATION_PUBLISH, version=self, token=action_token + constants.OPERATION_PUBLISH, + version=self, + token=action_token, + unpublished=list(to_unpublish), ) if emit_content_change: emit_content_change(self.content) @@ -384,6 +394,7 @@ def _set_publish(self, user): pass check_unpublish = Conditions([ + user_can_publish(permission_error_message), in_state([constants.PUBLISHED], _("Version is not in published state")), draft_is_not_locked(lock_draft_error_message), ]) @@ -391,11 +402,11 @@ def _set_publish(self, user): def can_be_unpublished(self): return can_proceed(self._set_unpublish) - def unpublish(self, user): + def unpublish(self, user, to_be_published=None): """Change state to UNPUBLISHED""" # trigger pre operation signal action_token = send_pre_version_operation( - constants.OPERATION_UNPUBLISH, version=self + constants.OPERATION_UNPUBLISH, version=self, to_be_published=to_be_published ) self._set_unpublish(user) self.modified = timezone.now() @@ -411,7 +422,10 @@ def unpublish(self, user): on_unpublish(self) # trigger post operation signal send_post_version_operation( - constants.OPERATION_UNPUBLISH, version=self, token=action_token + constants.OPERATION_UNPUBLISH, + version=self, + token=action_token, + to_be_published=to_be_published, ) if emit_content_change: emit_content_change(self.content) @@ -431,14 +445,60 @@ def _set_unpublish(self, user): possible to be left with inconsistent data)""" pass + def has_publish_permission(self, user) -> bool: + """ + Check if the given user has permission to publish. + + Args: + user (User): The user to check for permission. + + Returns: + bool: True if the user has publish permission, False otherwise. + """ + return self._has_permission("publish", user) + + def has_change_permission(self, user) -> bool: + """ + Check whether the given user has permission to change the object. + + Parameters: + user (User): The user for which permission needs to be checked. + + Returns: + bool: True if the user has permission to change the object, False otherwise. + """ + return self._has_permission("change", user) + + def _has_permission(self, perm: str, user) -> bool: + """ + Check if the user has the specified permission for the content by + checking the content's has_publish_permission, has_placeholder_change_permission, + or has_change_permission methods. + + Falls back to Djangos change permission for the content object. + """ + if perm == "publish" and hasattr(self.content, "has_publish_permission"): + # First try explicit publish permission + return self.content.has_publish_permission(user) + if hasattr(self.content, "has_change_permission"): + # First fallback: change permissions + return self.content.has_change_permission(user) + if hasattr(self.content, "has_placeholder_change_permission"): + # Second fallback: placeholder change permissions - works for PageContent + return self.content.has_placeholder_change_permission(user) + # final fallback: Django perms + return user.has_perm(f"{self.content_type.app_label}.change_{self.content_type.model}") + check_modify = Conditions( [ in_state([constants.DRAFT], not_draft_error), draft_is_not_locked(lock_draft_error_message), + user_can_unlock(permission_error_message), ] ) check_revert = Conditions( [ + user_can_change(permission_error_message), in_state( [constants.ARCHIVED, constants.UNPUBLISHED], _("Version is not in archived or unpublished state"), @@ -471,6 +531,7 @@ def _set_unpublish(self, user): [ in_state([constants.DRAFT, constants.PUBLISHED], not_draft_error), draft_is_locked(_("Draft version is not locked")) + ] ) diff --git a/djangocms_versioning/plugin_rendering.py b/djangocms_versioning/plugin_rendering.py index 7571bbaf..2ed05652 100644 --- a/djangocms_versioning/plugin_rendering.py +++ b/djangocms_versioning/plugin_rendering.py @@ -1,5 +1,6 @@ from functools import lru_cache +from cms import __version__ as cms_version from cms.plugin_rendering import ContentRenderer, StructureRenderer from cms.utils.placeholder import rescan_placeholders_for_obj @@ -41,29 +42,32 @@ def render_plugin(self, instance, context, placeholder=None, editable=False): prefetch_versioned_related_objects(instance, self.toolbar) return super().render_plugin(instance, context, placeholder, editable) - def render_obj_placeholder( - self, slot, context, inherit, nodelist=None, editable=True - ): - # FIXME This is an ad-hoc solution for page-specific rendering - # code, which by default doesn't work well with versioning. - # Remove this method once the issue is fixed. - from cms.models import Placeholder + if cms_version in ("4.1.0", "4.1.1"): + # Only needed for CMS 4.1.0 and 4.1.1 which have fix #7924 not merged + # With #7924, page-specific rendering works well with versioning. + def render_obj_placeholder( + self, slot, context, inherit, nodelist=None, editable=True + ): + # FIXME This is an ad-hoc solution for page-specific rendering + # code, which by default doesn't work well with versioning. + # Remove this method once the issue is fixed. + from cms.models import Placeholder - current_obj = self.toolbar.get_object() + current_obj = self.toolbar.get_object() - # Not page, therefore we will use toolbar object as - # the current object and render the placeholder - rescan_placeholders_for_obj(current_obj) - placeholder = Placeholder.objects.get_for_obj(current_obj).get(slot=slot) - content = self.render_placeholder( - placeholder, - context=context, - page=current_obj, - editable=editable, - use_cache=True, - nodelist=None, - ) - return content + # Not page, therefore we will use toolbar object as + # the current object and render the placeholder + rescan_placeholders_for_obj(current_obj) + placeholder = Placeholder.objects.get_for_obj(current_obj).get(slot=slot) + content = self.render_placeholder( + placeholder, + context=context, + page=current_obj, + editable=editable, + use_cache=True, + nodelist=None, + ) + return content class VersionStructureRenderer(StructureRenderer): diff --git a/djangocms_versioning/static/djangocms_versioning/css/object-tools.css b/djangocms_versioning/static/djangocms_versioning/css/object-tools.css new file mode 100644 index 00000000..6b7b671e --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/css/object-tools.css @@ -0,0 +1,8 @@ +.object-tools a.accent { + background-color: var(--accent) !important; +} +.object-tools a.accent:hover, +.object-tools a.accent:active, +.object-tools a.accent:hover:active { + background-color: color-mix(in srgb, var(--accent) 90%, var(--dca-black)) !important; +} diff --git a/djangocms_versioning/static/djangocms_versioning/css/versioning.css b/djangocms_versioning/static/djangocms_versioning/css/versioning.css index f66b94a0..bc19b68b 100644 --- a/djangocms_versioning/static/djangocms_versioning/css/versioning.css +++ b/djangocms_versioning/static/djangocms_versioning/css/versioning.css @@ -207,6 +207,3 @@ ins.cms-diff img { .cms-select::-ms-expand { opacity: 0; } -input.button.revert-button { - margin: 5px; -} diff --git a/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js b/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js new file mode 100644 index 00000000..632032fa --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js @@ -0,0 +1,22 @@ +(function () { + "use strict"; + + function closeSideFrame() { + try { + window.top.CMS.API.Sideframe.close(); + } catch (err) {} + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('form.js-close-sideframe').forEach(el => { + el.addEventListener("submit", (ev) => { + ev.preventDefault(); + ev.target.action = ev.target.action; // save action url + closeSideFrame(); + const form = window.top.document.body.appendChild(ev.target); // move to top window + form.style.display = 'none'; + form.submit(); // submit form + }); + }); + }); +})(); diff --git a/djangocms_versioning/static/djangocms_versioning/js/object-tools.js b/djangocms_versioning/static/djangocms_versioning/js/object-tools.js new file mode 100644 index 00000000..ca7e1e44 --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/js/object-tools.js @@ -0,0 +1,13 @@ +(function($) { + $(document).ready(function() { + $('.cms-form-post-method').on('click', function(e) { + e.preventDefault(); + var csrf_token = document.querySelector('form input[name="csrfmiddlewaretoken"]').value; + var url = this.href; + var $form = $('
'); + var $csrf = $(``); + $form.append($csrf); + $form.appendTo('body').submit(); + }); + }); +})(django.jQuery); diff --git a/djangocms_versioning/static/djangocms_versioning/js/versioning.js b/djangocms_versioning/static/djangocms_versioning/js/versioning.js new file mode 100644 index 00000000..0704f537 --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/js/versioning.js @@ -0,0 +1,37 @@ +(function() { + var firstChecked, lastChecked; + + function handleVersionSelection(event) { + if (firstChecked instanceof HTMLInputElement && firstChecked.checked) { + firstChecked.checked = false; + firstChecked.closest('tr').classList.remove('selected'); + firstChecked = lastChecked; + } + if (event.target instanceof HTMLInputElement) { + if (event.target.checked) { + firstChecked = lastChecked; + lastChecked = event.target; + } else if (firstChecked === event.target) { + firstChecked = null; + } else { + lastChecked = null; + } + } + } + + document.addEventListener('DOMContentLoaded', function(){ + var selectedVersions = document.querySelectorAll('#result_list input[type="checkbox"].action-select'); + var selectElement = document.querySelector('#changelist-form select[name="action"]'); + if (selectElement instanceof HTMLSelectElement) { + for (var i = 0; i < selectElement.options.length; i++) { + if (selectElement.options[i].value && selectElement.options[i].value !== 'compare_versions') { + // for future safety: do not restrict on two selected versions, since there might be other actions + return; + } + } + } + selectedVersions.forEach(function(selectedVersion){ + selectedVersion.addEventListener('change', handleVersionSelection); + }); + }); + })(); diff --git a/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html b/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html new file mode 100644 index 00000000..43039a22 --- /dev/null +++ b/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html @@ -0,0 +1,102 @@ +{% extends "admin/cms/page/change_form.html" %} +{% load static admin_urls admin_modify djangocms_versioning i18n cms_admin %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block content_title %} + {% if title %}

{{ title }}{% if original %} - {{ original.versions.first.short_name }}{% endif %}

{% endif %} + {% block object-tools %} + {% if not popup and not add %} +
    + {% block object-tools-items %} + {% include "admin/djangocms_versioning/versioning_buttons.html" %} +
  • + {% get_preview_url original as admin_url %} + + {% trans "Preview" %} + +
  • + {% endblock %} +
+ {% endif %} + {% endblock %} +{% endblock %} + +{% block content %} +
+ + + +
+{% csrf_token %} +{% block form_top %}{% endblock %} + +{% if show_language_tabs and not show_permissions %} +
+ {% for lang_code, lang_name in language_tabs %} + + {% endfor %} +
+
+{% endif %} + +
+{% if is_popup %}{% endif %} +{% if save_on_top %}{% submit_row %}{% endif %} +{% if errors %} +

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

+
    {% for error in adminform.form.non_field_errors %}
  • {{ error }}
  • {% endfor %}
+{% endif %} + +{% for fieldset in adminform %} + {% include "admin/cms/page/includes/fieldset.html" %} +{% endfor %} + +{% for inline_admin_formset in inline_admin_formsets %} + {% include inline_admin_formset.opts.template %} +{% endfor %} + +{% if show_permissions %} +
+ +
+{% endif %} + +{% block after_related_objects %}{% endblock %} + +{% if add %} +
+ + +
+{% else %} + {% page_submit_row %} +{% endif %} +
+
+
+ +{% block admin_change_form_document_ready %} +{{ block.super }} +{% endblock %} + +{# JavaScript for prepopulated fields #} +{% prepopulated_fields_js %} + +{% endblock %} diff --git a/djangocms_versioning/templates/admin/djangocms_versioning/versioning_buttons.html b/djangocms_versioning/templates/admin/djangocms_versioning/versioning_buttons.html new file mode 100644 index 00000000..f08c1d12 --- /dev/null +++ b/djangocms_versioning/templates/admin/djangocms_versioning/versioning_buttons.html @@ -0,0 +1,30 @@ +{% load djangocms_versioning i18n %} +{% with url=original|url_publish_version:request.user %} + {% if url %} +
  • + {% trans "Publish" %} +
  • + {% endif %} +{% endwith %} +{% with url=original|url_new_draft:request.user %} + {% if url %} +
  • + {% trans "New Draft" %} +
  • + {% endif %} +{% endwith %} +{% with url=original|url_revert_version:request.user %} + {% if url %} +
  • + {% trans "Revert" %} +
  • + {% endif %} +{% endwith %} + +{% with url=original|url_version_list %} + {% if url %} +
  • + {% trans "Versions" %} +
  • + {% endif %} +{% endwith %} diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/archive_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/archive_confirmation.html index 40ab5cd3..4a70749c 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/archive_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/archive_confirmation.html @@ -6,6 +6,7 @@ {{ block.super }} {{ media }} + {% endblock %} {% block breadcrumbs %}{% endblock %} @@ -15,15 +16,17 @@

    {% translate "Are you sure you want to archive the following version?" %}

    {{ object_name }}

    {% blocktrans %} Version number: {{ version_number }}{% endblocktrans %}

    -
    + {% csrf_token %} - - - - +
    + + + + +
    {% endblock %} diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/mixin/change_form.html b/djangocms_versioning/templates/djangocms_versioning/admin/mixin/change_form.html index 6fab19b2..32908d92 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/mixin/change_form.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/mixin/change_form.html @@ -1,12 +1,18 @@ {% extends versioning_fallback_change_form_template|default:"admin/change_form.html" %} -{% load i18n admin_urls djangocms_versioning %} +{% load static %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} {% block object-tools-items %} -
  • - - {% translate "Versions" %} - -
  • + {% include "admin/djangocms_versioning/versioning_buttons.html" %} {{ block.super }} {% endblock %} diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html index 3de7d687..7bbbb168 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html @@ -6,6 +6,7 @@ {{ media }} + {% endblock %} {% block breadcrumbs %}{% endblock %} @@ -22,20 +23,20 @@

    {% block title %}{% translate "Revert Confirmation" %}{% endblock %}

    {{ object_name }}

    {% blocktrans %}Version number: {{ version_number }}{% endblocktrans %}

    -
    + {% csrf_token %}
    {% if draft_version %} - - {% else %} - diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html index 7a87ab53..047dd547 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html @@ -5,6 +5,7 @@ {{ block.super }} {{ media }} + {% endblock %} {% block breadcrumbs %}{% endblock %} @@ -20,10 +21,10 @@

    {% blocktrans %} Version number: {{ version_number }}{% endblocktrans %}

    {{thing}}

    {% endfor %}
    - + {% csrf_token %}
    - diff --git a/djangocms_versioning/templatetags/djangocms_versioning.py b/djangocms_versioning/templatetags/djangocms_versioning.py index e6dae706..6b9bbcfe 100644 --- a/djangocms_versioning/templatetags/djangocms_versioning.py +++ b/djangocms_versioning/templatetags/djangocms_versioning.py @@ -1,5 +1,7 @@ from django import template +from django.urls import reverse +from .. import constants, versionables from ..helpers import version_list_url register = template.Library() @@ -8,3 +10,48 @@ @register.filter def url_version_list(content): return version_list_url(content) + +@register.filter +def url_publish_version(content, user): + if hasattr(content, "prefetched_versions"): + version = content.prefetched_versions[0] + else: + version = content.versions.first() + if version: + if version.check_publish.as_bool(user) and version.can_be_published(): + proxy_model = versionables.for_content(content).version_model_proxy + return reverse( + f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_publish", + args=(version.pk,), + ) + return "" + +@register.filter +def url_new_draft(content, user): + if hasattr(content, "prefetched_versions"): + version = content.prefetched_versions[0] + else: + version = content.versions.first() + if version: + if version.state == constants.PUBLISHED: + proxy_model = versionables.for_content(content).version_model_proxy + return reverse( + f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_edit_redirect", + args=(version.pk,), + ) + return "" + +@register.filter +def url_revert_version(content, user): + if hasattr(content, "prefetched_versions"): + version = content.prefetched_versions[0] + else: + version = content.versions.first() + if version: + if version.check_revert.as_bool(user): + proxy_model = versionables.for_content(content).version_model_proxy + return reverse( + f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_revert", + args=(version.pk,), + ) + return "" diff --git a/djangocms_versioning/test_utils/blogpost/models.py b/djangocms_versioning/test_utils/blogpost/models.py index 282d84c9..7634e5ad 100644 --- a/djangocms_versioning/test_utils/blogpost/models.py +++ b/djangocms_versioning/test_utils/blogpost/models.py @@ -14,6 +14,18 @@ class BlogContent(models.Model): language = models.TextField() text = models.TextField() + def has_publish_permission(self, user): + if user.is_superuser: + return True + # Fake a simple object-dependent permission + return user.username in self.text + + def has_change_permission(self, user): + if user.is_superuser: + return True + # Fake a simple object-dependent permission + return f"<{user.username}>" in self.text + def __str__(self): return self.text diff --git a/djangocms_versioning/test_utils/factories.py b/djangocms_versioning/test_utils/factories.py index 15a8a364..2eb97417 100644 --- a/djangocms_versioning/test_utils/factories.py +++ b/djangocms_versioning/test_utils/factories.py @@ -2,7 +2,12 @@ import factory from cms import constants -from cms.models import Page, PageContent, PageUrl, Placeholder, TreeNode +from cms.models import Page, PageContent, PageUrl, Placeholder + +try: + from cms.models import TreeNode +except ImportError: + TreeNode = None from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site @@ -170,18 +175,19 @@ def version(self, create, extracted, **kwargs): IncorrectBlogPostVersionFactory(content=self, **kwargs) -class TreeNodeFactory(factory.django.DjangoModelFactory): - site = factory.fuzzy.FuzzyChoice(Site.objects.all()) - depth = 0 - # NOTE: Generating path this way is probably not a good way of - # doing it, but seems to work for our present tests which only - # really need a tree node to exist and not throw unique constraint - # errors on this field. If the data in this model starts mattering - # in our tests then something more will need to be done here. - path = FuzzyText(length=8, chars=string.digits) +if TreeNode: + class TreeNodeFactory(factory.django.DjangoModelFactory): + site = factory.fuzzy.FuzzyChoice(Site.objects.all()) + depth = 0 + # NOTE: Generating path this way is probably not a good way of + # doing it, but seems to work for our present tests which only + # really need a tree node to exist and not throw unique constraint + # errors on this field. If the data in this model starts mattering + # in our tests then something more will need to be done here. + path = FuzzyText(length=8, chars=string.digits) - class Meta: - model = TreeNode + class Meta: + model = TreeNode class PageUrlFactory(factory.django.DjangoModelFactory): @@ -195,7 +201,12 @@ class Meta: class PageFactory(factory.django.DjangoModelFactory): - node = factory.SubFactory(TreeNodeFactory) + if TreeNode: + node = factory.SubFactory(TreeNodeFactory) + else: + site = factory.fuzzy.FuzzyChoice(Site.objects.all()) + depth = 0 + path = FuzzyText(length=8, chars=string.digits) class Meta: model = Page @@ -215,7 +226,7 @@ class PageContentFactory(AbstractContentFactory): soft_root = FuzzyChoice([True, False]) limit_visibility_in_menu = constants.VISIBILITY_USERS template = "page.html" - xframe_options = FuzzyInteger(0, 25) + xframe_options = FuzzyInteger(0, 3) class Meta: model = PageContent diff --git a/djangocms_versioning/test_utils/test_helpers.py b/djangocms_versioning/test_utils/test_helpers.py index 51db206f..bca487fc 100644 --- a/djangocms_versioning/test_utils/test_helpers.py +++ b/djangocms_versioning/test_utils/test_helpers.py @@ -20,7 +20,7 @@ def get_toolbar(content_obj, user=None, **kwargs): request = kwargs.get("request", RequestFactory().get("/")) request.user = user request.session = kwargs.get("session", {}) - request.current_page = getattr(content_obj, "page", None) + request.current_page = kwargs.get("current_page", getattr(content_obj, "page", None)) request.toolbar = CMSToolbar(request) # Set the toolbar class if kwargs.get("toolbar_class", False): diff --git a/docs/api/signals.rst b/docs/api/signals.rst index 9adc9074..73eb45f3 100644 --- a/docs/api/signals.rst +++ b/docs/api/signals.rst @@ -32,3 +32,18 @@ The CMS used to provide page publish and unpublish signals which have since been # ... do something +Handling the effect of a (un-)publish to other items via signals +---------------------------------------------------------------- + +Events often times do not happen in isolation. +A publish signal indicates a publish of an item but it also means that potentially other items are unpublished as part of the same action, also triggering unpublish signals. +To be able to react accordingly, information is added to the publish signal which other items were potentially unpublished as part of this action (`unpublished`) and information is also added to the unpublish singal which other items are going to get published (`to_be_published`). +This information allows you to differentiate if an item is published for the first time - because nothing is unpublished - or if it is just a new version of an existing item. + +For example, the differentiation can be benefitial if you integrate with other services like Elasticsearch and you want to update the Elasticsearch index via signals. You can get in the following situations: + - Publish signal with no unpublished item results in a new entry in the index. + - Publish signal with at least one unpublished item results in an update of an existing entry in the index. + - Unpublish singal with no to be published items results in the removal of the entry from the index. + - Unpublish signal with a to be published item results in the update on an existing entry in the index but will be handled in the corresponding publish signal and can be ignored. + +All those situations are distinct, require different information, and can be handled according to requirements. diff --git a/docs/basic_concepts.rst b/docs/basic_concepts.rst new file mode 100644 index 00000000..7519a204 --- /dev/null +++ b/docs/basic_concepts.rst @@ -0,0 +1,57 @@ +Introduction +============ + +djangocms-versioning is a general purpose package that manages versions +for page contents and other models within four categories: **published**, +**draft**, **unpublished**, or **archived**, called "version states". + + +Version states +-------------- + +Each versioned object carries a version number, creation date, modification date, a reference to the user who created the version, and **version state**. The states are: + + * **draft**: This is the version which currently can be edited. Only draft versions can + be edited and only one draft version per language is allowed. Changes made to draft + pages are not visible to the public. + * **published**: This is the version currently visible on the website to the public. Only + one version per language can be public. It cannot be changed. If it needs to be changed + a new draft is created based on a published page and the published page stays unchanged. + * **unpublished**: This is a version which was published at one time but now is not + visible to the public any more. There can be many unpublished versions. + * **archived**: This is a version which has not been published and therefore has never been + visible to the public. It represents a state which is intended to be used for + later work (by reverting it to a draft state). + +Each new draft version will generate a new version number. + +.. image:: /static/version-states.png + :align: center + :alt: Version states + +When an object is published, it changes state to **published** and thereby becomes publicly visible. All other version states are invisible to the public. + +Effect on the model's manager +----------------------------- + +When handling versioned models in code, you'll generally only "see" published objects: + +.. code-block:: + + MyModel.objects.filter(language="en") # get all published English objects of MyModel + +will return a queryset with published objects only. This is to ensure that no draft or unpublished versions leak or become visible to the public. + +Since often draft contents are the ones you interact with in the admin interface, or in draft mode in the CMS frontend, djangocms-versioning introduces an additional model manager for the versioned models **which may only be used on admin sites and admin forms**:: + + MyModel.admin_manager.filter(language="en") + +will retrieve all objects of all versions. Alternativley, to get the current draft version you can to filter the ``Version`` object:: + + from djangocms_versioning.constants import DRAFT + + MyModel.admin_manager.filter(language="en", versions__status==DRAFT) + +Finally, there are instance where you want to access the "current" version of a page. This is either the current draft version or - there is no draft - the published version. You can easily achieve this by using:: + + MyModel.admin_manager.filter(language="en").current_content() diff --git a/docs/index.rst b/docs/index.rst index 6b7bfa31..d09c98f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,7 +5,9 @@ Welcome to "djangocms-versioning"'s documentation! :maxdepth: 2 :caption: Quick Start: + basic_concepts versioning_integration + permissions version_locking .. toctree:: diff --git a/docs/permissions.rst b/docs/permissions.rst new file mode 100644 index 00000000..ab404720 --- /dev/null +++ b/docs/permissions.rst @@ -0,0 +1,94 @@ +##################################### + Permissions in djangocms-versioning +##################################### + +This documentation covers the permissions system introduced for +publishing and unpublishing content in djangocms-versioning. This system +allows for fine-grained control over who can publish and unpublish or otherwise +manage versions of content. + +*************************** + Understanding Permissions +*************************** + +Permissions are set at the content object level, allowing for detailed +access control based on the user's roles and permissions. The system +checks for specific methods within the **content object**, e.g. +``PageContent`` to determine if a user has the necessary permissions. + +- **Specific publish permission** (only for publish/unpublish action): + To check if a user has the + permission to publish content, the system looks for a method named + ``has_publish_permission`` on the content object. If this method is + present, it will be called to determine whether the user is allowed + to publish the content. + + Example: + + .. code:: python + + def has_publish_permission(self, user): + if user.is_superuser: + # Superusers typically have permission to publish + return True + # Custom logic to determine if the user can publish + return user_has_permission + +- **Change Permission** (and first fallback for ``has_publish_permission``): + If the content object has a + method named ``has_change_permission``, this method will be called to + assess if a user has the permission to change the content. This is a + general permission check that is not specific to publishing or + unpublishing actions. + + Example: + + .. code:: python + + def has_change_permission(self, user): + if user.is_superuser: + # Superusers typically have permission to publish + return True + # Custom logic to determine if the user can change the content + return user_has_permission + +- **First Fallback Placeholder Change Permission**: For content + objects that involve placeholders, such as PageContent objects, a + method named ``has_placeholder_change_permission`` is checked. This + method should determine if the user has the permission to change + placeholders within the content. + + Example: + + .. code:: python + + def has_placeholder_change_permission(self, user): + if user.is_superuser: + # Superusers typically have permission to publish + return True + # Custom logic to determine if the user can change placeholders + return user_has_permission + +- **Last resort Django permissions:** If none of the above methods are + present on the content object, the system falls back to checking if + the user has a generic Django permission to change ``Version`` + objects. This ensures that there is always a permission check in + place, even if specific methods are not implemented for the content + object. By default, the Django permissions are set on a user or group + level and include all instances of the content object. + + .. note:: + + It is highly recommended to implement the specific permission + methods on your content objects for more granular control over + user actions. + +************ + Conclusion +************ + +The permissions system introduced in djangocms-versioning for publishing +and unpublishing content provides a flexible and powerful way to manage +access to content. By defining custom permission logic within your +content objects, you can ensure that only authorized users are able to +perform these actions. diff --git a/docs/settings.rst b/docs/settings.rst index 231c2d4d..7747f40a 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -25,10 +25,12 @@ Settings for djangocms Versioning .. py:attribute:: DJANGOCMS_VERSIONING_ENABLE_MENU_REGISTRATION - Defaults to ``True`` + Defaults to ``True`` (for django CMS <= 4.1.0) and ``False`` + (for django CMS > 4.1.0) This settings specifies if djangocms-versioning should register its own - versioned CMS menu. + versioned CMS menu. This is necessary for CMS <= 4.1.0. For CMS > 4.1.0, the + django CMS core comes with a version-ready menu. The versioned CMS menu also shows draft content in edit and preview mode. @@ -37,6 +39,9 @@ Settings for djangocms Versioning Defaults to ``False`` + .. versionadded:: 2.0 + Before version 2.0 version locking was part of a separate package. + This setting controls if draft versions are locked. If they are, only the user who created the draft can change the draft. See :ref:`Locking versions ` for more details. @@ -60,3 +65,27 @@ Settings for djangocms Versioning will fail. +.. py:attribute:: DJANGOCMS_VERSIONING_ON_PUBLISH_REDIRECT + + Defaults to ``"published"`` + + .. versionadded:: 2.0 + Before version 2.0 the behavior was always ``"versions"``. + + This setting determines what happens after publication/unpublication of a + content object. Three options exist: + + * ``"versions"``: The user will be redirected to a version overview of + the current object. This is particularly useful for advanced users who + need to keep a regular overview on the existing versions. + + * ``"published"``: The user will be redirected to the content object on + the site. Its URL is determined by calling ``.get_absolute_url()`` on + the content object. If does not have an absolute url or the object was + unpublished the user is redirected to the object's preview endpoint. + This is particularly useful if users only want to interact with versions + if necessary. + + * ``"preview"``: The user will be redirected to the content object's + preview endpoint. + diff --git a/docs/static/blog-new.jpg b/docs/static/blog-new.jpg new file mode 100644 index 00000000..2df4f311 Binary files /dev/null and b/docs/static/blog-new.jpg differ diff --git a/docs/static/blog-original.jpg b/docs/static/blog-original.jpg new file mode 100644 index 00000000..014a5a3d Binary files /dev/null and b/docs/static/blog-original.jpg differ diff --git a/docs/static/version-states.png b/docs/static/version-states.png new file mode 100644 index 00000000..d0b8af84 Binary files /dev/null and b/docs/static/version-states.png differ diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 45e513e9..21892791 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -17,30 +17,13 @@ Change the model structure ---------------------------- Assuming that our `blog` app has one db table: -.. graphviz:: - - digraph ERD1 { - graph [ rankdir = "LR" ]; - ranksep=2; - - "Post" [ label=" Post|id \l |site \l title \l text \l " shape = "record" ]; - - "Post":"PK_GROUPER_ID" [arrowhead = crow]; - } +.. image:: /static/blog-original.jpg + :width: 75px This would have to change to a db structure like this: -.. graphviz:: - - digraph ERD2 { - graph [ rankdir = "LR" ]; - ranksep=2; - - "Post" [ label=" Post|id \l |site \l " shape = "record" ]; - "PostContent" [ label=" PostContent|id \l |post \l |title \l text \l " shape = "record" ]; - - "Post":"PK_GROUPER_ID"->"PostContent":"FK_POST" [arrowhead = crow]; - } +.. image:: /static/blog-new.jpg + :width: 377px Or in python code, `models.py` would need to change from: diff --git a/pyproject.toml b/pyproject.toml index c6a1005d..43360176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,19 @@ [tool.ruff] -# https://beta.ruff.rs/docs/configuration/ +exclude = [ + ".eggs", + ".git", + ".mypy_cache", + ".ruff_cache", + ".env", + ".venv", + "**migrations/**", + "node_modules", + "venv", +] line-length = 120 + +[tool.ruff.lint] +# https://beta.ruff.rs/docs/configuration/ select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -15,18 +28,6 @@ select = [ "UP", # pyupgrade ] -exclude = [ - ".eggs", - ".git", - ".mypy_cache", - ".ruff_cache", - ".env", - ".venv", - "**migrations/**", - "node_modules", - "venv", -] - ignore = [ "B006", # Do not use mutable data structures for argument defaults "B011", # tests use assert False @@ -43,12 +44,12 @@ ignore = [ "UP007", # Use `X | Y` for type annotations ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "__init__.py" = [ "F401" # unused-import ] -[tool.ruff.isort] +[tool.ruff.lint.isort] combine-as-imports = true known-first-party = [ "djangocms_versioning", diff --git a/setup.py b/setup.py index 9ddc378b..aa90ca36 100644 --- a/setup.py +++ b/setup.py @@ -3,9 +3,9 @@ import djangocms_versioning INSTALL_REQUIREMENTS = [ - "Django>=1.11", - "django-cms", - "django-fsm" + "Django>=3.2", + "django-cms>=4.1.1", + "django-fsm<3" ] setup( diff --git a/test_settings.py b/test_settings.py index b7ba9a75..65950588 100644 --- a/test_settings.py +++ b/test_settings.py @@ -44,6 +44,7 @@ "PARLER_ENABLE_CACHING": False, "LANGUAGE_CODE": "en", "DEFAULT_AUTO_FIELD": "django.db.models.AutoField", + "DJANGOCMS_VERSIONING_ENABLE_MENU_REGISTRATION": True, "CMS_CONFIRM_VERSION4": True, } diff --git a/tests/requirements/dj32_cms41.txt b/tests/requirements/dj32_cms41.txt index 8e55074c..aaafdfa5 100644 --- a/tests/requirements/dj32_cms41.txt +++ b/tests/requirements/dj32_cms41.txt @@ -1,6 +1,8 @@ -r requirements_base.txt +django-cms>=4.1,<4.2 + Django>=3.2,<4.0 django-classy-tags -django-fsm>=2.6 +django-fsm>=2.6,<3 django-sekizai diff --git a/tests/requirements/dj42_cms41.txt b/tests/requirements/dj42_cms41.txt index 20ef631b..bf600a57 100644 --- a/tests/requirements/dj42_cms41.txt +++ b/tests/requirements/dj42_cms41.txt @@ -1,6 +1,8 @@ -r requirements_base.txt +django-cms>=4.1,<4.2 + Django>=4.2,<5 django-classy-tags -django-fsm>=2.6 +django-fsm>=2.6,<3 django-sekizai diff --git a/tests/requirements/dj40_cms41.txt b/tests/requirements/dj50_cms41.txt similarity index 50% rename from tests/requirements/dj40_cms41.txt rename to tests/requirements/dj50_cms41.txt index 53c58914..4326bfd7 100644 --- a/tests/requirements/dj40_cms41.txt +++ b/tests/requirements/dj50_cms41.txt @@ -1,6 +1,8 @@ -r requirements_base.txt -Django>=4.0,<4.1 +django-cms>=4.1,<4.2 + +Django>=5.0,<5.1 django-classy-tags -django-fsm>=2.6 +django-fsm>=2.6,<3 django-sekizai diff --git a/tests/requirements/dj41_cms41.txt b/tests/requirements/dj51_cms41.txt similarity index 50% rename from tests/requirements/dj41_cms41.txt rename to tests/requirements/dj51_cms41.txt index a839ca44..14b5770e 100644 --- a/tests/requirements/dj41_cms41.txt +++ b/tests/requirements/dj51_cms41.txt @@ -1,6 +1,8 @@ -r requirements_base.txt -Django>=4.1,<4.2 +django-cms>=4.1,<4.2 + +Django>=5.1,<5.2 django-classy-tags -django-fsm>=2.6 +django-fsm>=2.6,<3 django-sekizai diff --git a/tests/requirements/requirements_base.txt b/tests/requirements/requirements_base.txt index 5ffedc51..1be2a30c 100644 --- a/tests/requirements/requirements_base.txt +++ b/tests/requirements/requirements_base.txt @@ -1,10 +1,10 @@ +setuptools beautifulsoup4 coverage django-app-helper factory-boy -flake8 +ruff freezegun -isort lxml mock pillow @@ -12,7 +12,6 @@ pyflakes>=2.1.1 python-dateutil mysqlclient==2.0.3 psycopg2 +setuptools djangocms-text-ckeditor>=5.1.2 -# Unreleased django-cms 4.0 compatible packages -django-cms>=4.1.0rc2 diff --git a/tests/test_admin.py b/tests/test_admin.py index 563d74bd..163d606a 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -25,6 +25,7 @@ from django.urls import reverse from django.utils.http import urlencode from django.utils.timezone import now +from django.utils.translation import override from freezegun import freeze_time import djangocms_versioning.helpers @@ -50,6 +51,7 @@ BlogContentFactory, BlogPostFactory, BlogPostVersionFactory, + PollVersionFactory, ) from djangocms_versioning.test_utils.incorrectly_configured_blogpost.models import ( IncorrectBlogContent, @@ -57,6 +59,10 @@ from djangocms_versioning.test_utils.polls.cms_config import PollsCMSConfig from djangocms_versioning.test_utils.polls.models import Answer, Poll, PollContent +if not hasattr(CMSTestCase, "assertQuerySetEqual"): + # Django < 4.2 + CMSTestCase.assertQuerySetEqual = CMSTestCase.assertQuerysetEqual + class BaseStateTestCase(CMSTestCase): def assertRedirectsToVersionList(self, response, version): @@ -74,6 +80,26 @@ def assertRedirectsToVersionList(self, response, version): }, ) + def assertRedirectsToPreview(self, response, version): + parsed = urlparse(response.url) + self.assertEqual(response.status_code, 302) + self.assertEqual( + parsed.path, + helpers.get_preview_url(version.content), + ) + + def assertRedirectsToPublished(self, response, version): + if hasattr(version.content, "get_absolute_url"): + published_url = version.content.get_absolute_url() + else: + published_url = helpers.get_preview_url(version.content) + parsed = urlparse(response.url) + self.assertEqual(response.status_code, 302) + self.assertEqual( + parsed.path, + published_url, + ) + class AdminVersioningTestCase(CMSTestCase): def test_admin_factory(self): @@ -247,7 +273,7 @@ def test_only_fetches_latest_content_records(self): with self.login_user_context(self.get_superuser()): response = self.client.get(self.get_admin_url(PollContent, "changelist")) - self.assertQuerysetEqual( + self.assertQuerySetEqual( response.context["cl"].queryset, [poll_content1.pk, poll_content2.pk, poll_content3.pk], transform=lambda x: x.pk, @@ -270,7 +296,7 @@ def test_records_filtering_is_generic(self): with self.login_user_context(self.get_superuser()): response = self.client.get(self.get_admin_url(BlogContent, "changelist")) - self.assertQuerysetEqual( + self.assertQuerySetEqual( response.context["cl"].queryset, [blog_content1.pk, blog_content2.pk], transform=lambda x: x.pk, @@ -388,10 +414,7 @@ def test_content_link_editable_object(self): The link returned is the change url for an editable object """ version = factories.PageVersionFactory(content__title="mypage") - preview_url = admin_reverse( - "cms_placeholder_render_object_preview", - args=(version.content_type_id, version.object_id), - ) + preview_url = helpers.get_preview_url(version.content) self.assertEqual( self.site._registry[Version].content_link(version), f'{version.content}', @@ -429,13 +452,13 @@ def test_content_link_for_editable_object_with_no_preview_url(self): """ version = factories.PageVersionFactory(content__title="test5") with patch.object(helpers, "is_editable_model", return_value=True): - self.assertEqual( - self.site._registry[Version].content_link(version), - '{label}'.format( - url=get_object_preview_url(version.content), - label=version.content - ), - ) + with override(version.content.language): + url = get_object_preview_url(version.content, language=version.content.language) + label = version.content + self.assertEqual( + self.site._registry[Version].content_link(version), + f'{label}', + ) class VersionAdminActionsTestCase(CMSTestCase): @@ -493,7 +516,7 @@ def test_revert_action_link_enable_state(self): The revert action is active """ version = factories.PollVersionFactory(state=constants.ARCHIVED) - user = factories.UserFactory() + user = self.get_superuser() request = RequestFactory().get("/admin/polls/pollcontent/") version.created_by = request.user = user actual_enabled_control = self.version_admin._get_revert_link(version, request) @@ -506,9 +529,9 @@ def test_revert_action_link_enable_state(self): 'cms-action-revert ' 'js-action ' 'js-keep-sideframe" ' - 'href="%s" ' + f'href="{draft_revert_url}" ' 'title="Revert">' - ) % draft_revert_url + ) self.assertIn(expected_enabled_state, actual_enabled_control.replace("\n", "")) def test_revert_action_link_for_draft_state(self): @@ -546,7 +569,7 @@ def test_discard_version_through_post_action(self): self.versionable.version_model_proxy, "discard", version.pk ) request = RequestFactory().post(draft_discard_url, {"discard": "1"}) - request.user = factories.UserFactory() + request.user = self.get_superuser() request.session = "session" messages = FallbackStorage(request) @@ -564,7 +587,7 @@ def test_discard_action_link_enabled_state(self): """ version = factories.PollVersionFactory(state=constants.DRAFT) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() draft_discard_url = self.get_admin_url( self.versionable.version_model_proxy, "discard", version.pk ) @@ -577,9 +600,9 @@ def test_discard_action_link_enabled_state(self): 'cms-action-discard ' 'js-action ' 'js-keep-sideframe" ' - 'href="%s" ' + f'href="{draft_discard_url}" ' 'title="Discard">' - ) % draft_discard_url + ) self.assertIn(expected_enabled_state, actual_enabled_control.replace("\n", "")) def test_discard_action_link_for_archive_state(self): @@ -626,7 +649,7 @@ def test_revert_action_link_for_archive_state(self): The revert url should be null for unpublished state """ version = factories.PollVersionFactory(state=constants.UNPUBLISHED) - user = factories.UserFactory() + user = self.get_superuser() archive_version = version.copy(user) archive_version.archive(user) request = RequestFactory().get("/admin/polls/pollcontent/") @@ -642,11 +665,11 @@ def test_revert_action_link_for_archive_state(self): 'cms-action-revert ' 'js-action ' 'js-keep-sideframe" ' - 'href="%s" ' + f'href="{draft_revert_url}" ' 'title="Revert">' '' '' - ) % draft_revert_url + ) self.assertIn( expected_disabled_control, actual_disabled_control.replace("\n", "") @@ -707,7 +730,7 @@ class StateActionsTestCase(CMSTestCase): def test_archive_in_state_actions_for_draft_version(self): version = factories.PollVersionFactory(state=constants.DRAFT) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -783,7 +806,7 @@ def test_archive_not_in_state_actions_for_unpublished_version(self): def test_publish_in_state_actions_for_draft_version(self): version = factories.PollVersionFactory(state=constants.DRAFT) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -859,7 +882,7 @@ def test_publish_not_in_state_actions_for_unpublished_version(self): def test_unpublish_in_state_actions_for_published_version(self): version = factories.PollVersionFactory(state=constants.PUBLISHED) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -939,7 +962,7 @@ def test_unpublish_not_in_state_actions_for_draft_version(self): def test_edit_in_state_actions_for_draft_version(self): version = factories.PollVersionFactory(state=constants.DRAFT) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -979,7 +1002,7 @@ def test_edit_not_in_state_actions_for_archived_version(self): def test_edit_in_state_actions_for_published_version(self): version = factories.PollVersionFactory(state=constants.PUBLISHED) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -1106,7 +1129,7 @@ def test_archive_view_sets_modified_time(self): url = self.get_admin_url( self.versionable.version_model_proxy, "archive", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with freeze_time("2999-01-11 00:00:00", tz_offset=0), self.login_user_context( user ): @@ -1142,7 +1165,7 @@ def test_archive_view_sets_state_and_redirects(self, mocked_messages): url = self.get_admin_url( self.versionable.version_model_proxy, "archive", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with self.login_user_context(user): response = self.client.post(url) @@ -1265,7 +1288,7 @@ def test_archive_view_can_be_accessed_by_get_request(self): self.versionable.version_model_proxy, "archive", poll_version.pk ) - with self.login_user_context(self.get_staff_user_with_no_permissions()): + with self.login_user_context(self.get_superuser()): response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -1302,7 +1325,7 @@ def test_publish_view_sets_state_and_redirects(self, mocked_messages): url = self.get_admin_url( self.versionable.version_model_proxy, "publish", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with self.login_user_context(user): response = self.client.post(url) @@ -1321,14 +1344,52 @@ def test_publish_view_sets_state_and_redirects(self, mocked_messages): self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], "Version published") # Redirect happened + self.assertRedirectsToPublished(response, poll_version) + + def test_publish_view_redirects_according_to_settings(self): + from djangocms_versioning import conf + + original_setting = conf.ON_PUBLISH_REDIRECT + user = self.get_superuser() + + conf.ON_PUBLISH_REDIRECT ="published" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToPublished(response, poll_version) + + conf.ON_PUBLISH_REDIRECT ="preview" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToPreview(response, poll_version) + + conf.ON_PUBLISH_REDIRECT ="versions" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) self.assertRedirectsToVersionList(response, poll_version) + conf.ON_PUBLISH_REDIRECT = original_setting + def test_published_view_sets_modified_time(self): poll_version = factories.PollVersionFactory(state=constants.DRAFT) url = self.get_admin_url( self.versionable.version_model_proxy, "publish", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with freeze_time("2999-01-11 00:00:00", tz_offset=0), self.login_user_context( user ): @@ -1354,7 +1415,7 @@ def test_publish_view_cannot_be_accessed_for_archived_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1378,7 +1439,7 @@ def test_publish_view_cannot_be_accessed_for_published_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1402,7 +1463,7 @@ def test_publish_view_cannot_be_accessed_for_unpublished_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1488,7 +1549,7 @@ def test_unpublish_view_sets_state_and_redirects(self, mocked_messages): url = self.get_admin_url( self.versionable.version_model_proxy, "unpublish", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with self.login_user_context(user): response = self.client.post(url) @@ -1507,14 +1568,14 @@ def test_unpublish_view_sets_state_and_redirects(self, mocked_messages): self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], "Version unpublished") # Redirect happened - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) def test_unpublish_view_sets_modified_time(self): poll_version = factories.PollVersionFactory(state=constants.PUBLISHED) url = self.get_admin_url( self.versionable.version_model_proxy, "unpublish", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with freeze_time("2999-01-11 00:00:00", tz_offset=0), self.login_user_context( user ): @@ -1540,7 +1601,7 @@ def test_unpublish_view_cannot_be_accessed_for_archived_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1566,7 +1627,7 @@ def test_unpublish_view_cannot_be_accessed_for_unpublished_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1587,7 +1648,7 @@ def test_unpublish_view_cannot_be_accessed_for_draft_version(self, mocked_messag with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1631,7 +1692,7 @@ def test_unpublish_view_can_be_accessed_by_get_request(self): self.versionable.version_model_proxy, "unpublish", poll_version.pk ) - with self.login_user_context(self.get_staff_user_with_no_permissions()): + with self.login_user_context(self.get_superuser()): response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -1668,7 +1729,7 @@ def publish_context(request, version, *args, **kwargs): } with patch.object(versioning_ext, "add_to_context", extra_context_setting): - with self.login_user_context(self.get_staff_user_with_no_permissions()): + with self.login_user_context(self.get_superuser()): response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -1701,11 +1762,49 @@ def test_unpublish_view_doesnt_throw_exception_if_no_app_registered_extra_unpubl versioning_ext = apps.get_app_config("djangocms_versioning").cms_extension with patch.object(versioning_ext, "add_to_context", {}): - with self.login_user_context(self.get_staff_user_with_no_permissions()): + with self.login_user_context(self.get_superuser()): response = self.client.get(url) self.assertEqual(response.status_code, 200) + def test_unpublish_view_redirects_according_to_settings(self): + from djangocms_versioning import conf + + original_setting = conf.ON_PUBLISH_REDIRECT + user = self.get_staff_user_with_no_permissions() + + conf.ON_PUBLISH_REDIRECT ="published" + poll_version = factories.PollVersionFactory(state=constants.PUBLISHED) + url = self.get_admin_url( + self.versionable.version_model_proxy, "unpublish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToPreview(response, poll_version) + + conf.ON_PUBLISH_REDIRECT ="preview" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToPreview(response, poll_version) + + conf.ON_PUBLISH_REDIRECT ="versions" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToVersionList(response, poll_version) + + conf.ON_PUBLISH_REDIRECT = original_setting + class RevertViewTestCase(BaseStateTestCase): def setUp(self): @@ -1716,7 +1815,7 @@ def test_revert_view_sets_modified_time(self): url = self.get_admin_url( self.versionable.version_model_proxy, "revert", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with freeze_time("2999-01-11 00:00:00", tz_offset=0), self.login_user_context( user ): @@ -2019,10 +2118,7 @@ def test_compare_view_has_version_data_in_context_when_no_get_param(self): self.assertIn("v1", context) self.assertEqual(context["v1"], versions[0]) self.assertIn("v1_preview_url", context) - v1_preview_url = reverse( - "admin:cms_placeholder_render_object_preview", - args=(versions[0].content_type_id, versions[0].object_id), - ) + v1_preview_url = helpers.get_preview_url(versions[0].content) parsed = urlparse(context["v1_preview_url"]) self.assertEqual(parsed.path, v1_preview_url) self.assertEqual( @@ -2032,7 +2128,7 @@ def test_compare_view_has_version_data_in_context_when_no_get_param(self): self.assertNotIn("v2", context) self.assertNotIn("v2_preview_url", context) self.assertIn("version_list", context) - self.assertQuerysetEqual( + self.assertQuerySetEqual( context["version_list"], [versions[0].pk, versions[1].pk], transform=lambda o: o.pk, @@ -2074,10 +2170,7 @@ def test_compare_view_has_version_data_in_context_when_version2_in_get_param(sel self.assertIn("v1", context) self.assertEqual(context["v1"], versions[0]) self.assertIn("v1_preview_url", context) - v1_preview_url = reverse( - "admin:cms_placeholder_render_object_preview", - args=(versions[0].content_type_id, versions[0].object_id), - ) + v1_preview_url = helpers.get_preview_url(versions[0].content) parsed = urlparse(context["v1_preview_url"]) self.assertEqual(parsed.path, v1_preview_url) self.assertEqual( @@ -2087,10 +2180,7 @@ def test_compare_view_has_version_data_in_context_when_version2_in_get_param(sel self.assertIn("v2", context) self.assertEqual(context["v2"], versions[1]) self.assertIn("v2_preview_url", context) - v2_preview_url = reverse( - "admin:cms_placeholder_render_object_preview", - args=(versions[1].content_type_id, versions[1].object_id), - ) + v2_preview_url = helpers.get_preview_url(versions[1].content) parsed = urlparse(context["v2_preview_url"]) self.assertEqual(parsed.path, v2_preview_url) self.assertEqual( @@ -2098,7 +2188,7 @@ def test_compare_view_has_version_data_in_context_when_version2_in_get_param(sel self.disable_toolbar_params, ) self.assertIn("version_list", context) - self.assertQuerysetEqual( + self.assertQuerySetEqual( context["version_list"], [versions[0].pk, versions[1].pk, versions[2].pk], transform=lambda o: o.pk, @@ -2224,7 +2314,7 @@ def test_grouper_filtering(self): self.assertEqual(response.status_code, 200) self.assertIn("cl", response.context) - self.assertQuerysetEqual( + self.assertQuerySetEqual( response.context["cl"].queryset, [pv.pk], transform=lambda x: x.pk, @@ -2281,9 +2371,7 @@ def test_changelist_view_displays_correct_breadcrumbs(self): expected = """""" self.assertEqual(str(breadcrumb_html), expected) @@ -2332,12 +2420,11 @@ def test_changelist_view_displays_correct_breadcrumbs_for_extra_grouping_values( breadcrumb_html = soup.find("div", class_="breadcrumbs") # Assert the breadcrumbs - we should have ignored the French one # and put the English one in the breadcrumbs + pk = page_content_en.pk expected = """""" self.assertEqual(str(breadcrumb_html), expected) @@ -2644,10 +2731,10 @@ def test_extended_version_change_list_display_renders_from_provided_list_display self.assertEqual(200, response.status_code) # Check list_display item is rendered - self.assertContains(response, '[TEST]{}'.format( - content.id, - content.text - )) + self.assertContains( + response, + f'[TEST]{content.text}' + ) # Check list_action links are rendered self.assertContains(response, "cms-action-btn") self.assertContains(response, "cms-action-preview") @@ -2872,10 +2959,10 @@ def test_extended_grouper_change_list_display_renders_from_provided_list_display # Check response is valid self.assertEqual(200, response.status_code) # Check list_display item is rendered - self.assertContains(response, '[TEST]{}'.format( - content.poll.id, - content.text - )) + self.assertContains( + response, + f'[TEST]{content.text}', + ) # Check list_action links are rendered self.assertContains(response, "cms-action-btn") self.assertContains(response, "cms-action-view") @@ -3145,3 +3232,77 @@ def test_fake_back_link(self): response = self.client.get(changelist + "?back=/hijack_url") self.assertNotContains(response, "hijack_url") self.assertContains(response, version_list_url(version.content)) + +class VersioningAdminButtonsTestCase(CMSTestCase): + def _get_versioning_url(self, version, action, versionable=PollsCMSConfig.versioning[0]): + """Helper method to return the expected action url + """ + admin_url = self.get_admin_url( + versionable.version_model_proxy, action, version.pk + ) + return admin_url + + def get_change_view_url(self, content): + return self.get_admin_url( + content.__class__, + "change", + content.pk, + ) + + def test_buttons_in_draft_changeview(self): + """Only publish button should be visible in draft mode""" + version = PollVersionFactory(state=constants.DRAFT) + action_url = self._get_versioning_url(version, "publish") + next_url = self.get_change_view_url(version.content) + expected_button = ('Publish') + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.get_change_view_url(version.content)) + + self.assertContains(response, expected_button) + self.assertNotContains(response, "Revert") + self.assertNotContains(response, "New Draft") + + def test_buttons_in_published_changeview(self): + """Only revert button should be visible in published mode""" + version = PollVersionFactory(state=constants.PUBLISHED) + action_url = self._get_versioning_url(version, "edit_redirect") + expected_button = ('New Draft') + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.get_change_view_url(version.content)) + + self.assertContains(response, expected_button) + self.assertNotContains(response, "Revert") + self.assertNotContains(response, "Publish") + + def test_buttons_in_unpublished_changeview(self): + """Only revert button should be visible in unpublished mode""" + version = PollVersionFactory(state=constants.UNPUBLISHED) + action_url = self._get_versioning_url(version, "revert") + next_url = self.get_change_view_url(version.content) + expected_button = f'Revert' + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.get_change_view_url(version.content)) + + self.assertContains(response, expected_button) + self.assertNotContains(response, "New Draft") + self.assertNotContains(response, "Publish") + + def test_buttons_in_archived_changeview(self): + """Only revert button should be visible in archived mode""" + version = PollVersionFactory(state=constants.ARCHIVED) + action_url = self._get_versioning_url(version, "revert") + next_url = self.get_change_view_url(version.content) + expected_button = f'Revert' + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.get_change_view_url(version.content)) + + self.assertContains(response, expected_button) + self.assertNotContains(response, "New Draft") + self.assertNotContains(response, "Publish") + diff --git a/tests/test_cms_config.py b/tests/test_cms_config.py index 13153e12..001f4520 100644 --- a/tests/test_cms_config.py +++ b/tests/test_cms_config.py @@ -119,7 +119,7 @@ def test_changing_slug_changes_page_url(self): form = ChangePageForm(data, instance=self.content) form._request = request form._site = self.site - self.assertEqual(form.is_valid(), True) + self.assertTrue(form.is_valid(), f"Form errors: {form.errors}") form.save() page = Page.objects.get(pk=self.page.pk) diff --git a/tests/test_content_models.py b/tests/test_content_models.py index 0a4d07a1..4117446c 100644 --- a/tests/test_content_models.py +++ b/tests/test_content_models.py @@ -68,7 +68,7 @@ def setUp(self) -> None: self.create_page_content(page, "it", constants.ARCHIVED) self.create_page_content(page, "it", constants.PUBLISHED) - def test_current_content_iterator(self): + def test_current_content(self): # 12 PageContent versions in total self.assertEqual(len(list( PageContent.admin_manager.all() @@ -79,11 +79,11 @@ def test_current_content_iterator(self): self.assertEqual(len(qs), 4) self.assertEqual(qs._group_by_key, ["page", "language"]) self.assertEqual(len(list( - PageContent.admin_manager.filter(page__in=self.pages1).current_content_iterator() - )), 4, f"{list(PageContent.admin_manager.filter(page__in=self.pages1).current_content_iterator())}") + PageContent.admin_manager.filter(page__in=self.pages1).current_content() + )), 4, f"{list(PageContent.admin_manager.filter(page__in=self.pages1).current_content())}") # 2 current PageContent versions for self.pages2 self.assertEqual(len(list( - PageContent.admin_manager.filter(page__in=self.pages2).current_content_iterator() + PageContent.admin_manager.filter(page__in=self.pages2).current_content() )), 4) # Now unpublish all published in pages2 @@ -93,5 +93,5 @@ def test_current_content_iterator(self): # 2 current PageContent versions for self.pages2 self.assertEqual(len(list( - PageContent.admin_manager.filter(page__in=self.pages2).current_content_iterator() + PageContent.admin_manager.filter(page__in=self.pages2).current_content() )), 2) diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index cdea40c9..633ec384 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -11,6 +11,10 @@ from djangocms_versioning.test_utils.people.models import PersonContent from djangocms_versioning.test_utils.polls.models import Poll, PollContent +if not hasattr(CMSTestCase, "assertQuerySetEqual"): + # Django < 4.2 + CMSTestCase.assertQuerySetEqual = CMSTestCase.assertQuerysetEqual + class VersionableItemTestCase(CMSTestCase): def setUp(self): @@ -31,7 +35,7 @@ def test_distinct_groupers(self): grouper_field_name="poll", copy_function=default_copy, ) - self.assertQuerysetEqual( + self.assertQuerySetEqual( versionable.distinct_groupers(), [latest_poll1_version.content.pk, latest_poll2_version.content.pk], transform=lambda x: x.pk, @@ -59,7 +63,7 @@ def test_queryset_filter_for_distinct_groupers(self): qs_published_filter = {"versions__state__in": [PUBLISHED]} # Should be one published version - self.assertQuerysetEqual( + self.assertQuerySetEqual( versionable.distinct_groupers(**qs_published_filter), [poll1_published_version.content.pk], transform=lambda x: x.pk, @@ -68,7 +72,7 @@ def test_queryset_filter_for_distinct_groupers(self): qs_archive_filter = {"versions__state__in": [ARCHIVED]} # Should be two archived versions - self.assertQuerysetEqual( + self.assertQuerySetEqual( versionable.distinct_groupers(**qs_archive_filter), [poll1_archived_version.content.pk, poll2_archived_version.content.pk], transform=lambda x: x.pk, @@ -89,7 +93,7 @@ def test_for_grouper(self): copy_function=default_copy, ) - self.assertQuerysetEqual( + self.assertQuerySetEqual( versionable.for_grouper(self.initial_version.content.poll), [self.initial_version.content.pk, poll1_version2.content.pk], transform=lambda x: x.pk, diff --git a/tests/test_extensions.py b/tests/test_extensions.py index bcb6c35e..9d1b1a01 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -117,7 +117,7 @@ def test_title_extension_admin_monkey_patch_save(self): poll_extension = PollTitleExtensionFactory(extended_object=self.version.content) model_site = PollExtensionAdmin(admin_site=admin.AdminSite(), model=PollPageContentExtension) test_url = admin_reverse("extended_polls_pollpagecontentextension_change", args=(poll_extension.pk,)) - test_url += "?extended_object=%s" % self.version.content.pk + test_url += f"?extended_object={self.version.content.pk}" request = RequestFactory().post(path=test_url) request.user = self.get_superuser() @@ -137,7 +137,7 @@ def test_title_extension_admin_monkey_patch_save_date_modified_updated(self): model_site = PollExtensionAdmin(admin_site=admin.AdminSite(), model=PollPageContentExtension) pre_changes_date_modified = Version.objects.get(id=self.version.pk).modified test_url = admin_reverse("extended_polls_pollpagecontentextension_change", args=(poll_extension.pk,)) - test_url += "?extended_object=%s" % self.version.content.pk + test_url += f"?extended_object={self.version.content.pk}" request = RequestFactory().post(path=test_url) request.user = self.get_superuser() @@ -155,7 +155,7 @@ def test_title_extension_admin_monkeypatch_add_view(self): with self.login_user_context(self.get_superuser()): response = self.client.get( admin_reverse("extended_polls_pollpagecontentextension_add") + - "?extended_object=%s" % self.version.content.pk, + f"?extended_object={self.version.content.pk}", follow=True ) self.assertEqual(response.status_code, 200) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index c9cf0a90..06d426b3 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -21,6 +21,7 @@ def test_modified_date(self): def test_add_plugin(self): version = factories.PageVersionFactory() placeholder = factories.PlaceholderFactory(source=version.content) + placeholder.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI poll = factories.PollFactory() dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -45,6 +46,7 @@ def test_change_plugin(self): plugin = add_plugin( placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -61,6 +63,7 @@ def test_change_plugin(self): def test_clear_placeholder(self): version = factories.PageVersionFactory() placeholder = factories.PlaceholderFactory(source=version.content) + placeholder.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -81,6 +84,7 @@ def test_delete_plugin(self): plugin = add_plugin( placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -103,6 +107,7 @@ def test_add_plugins_from_placeholder(self): plugin = add_plugin( source_placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -165,7 +170,7 @@ def test_paste_plugin(self): plugin = add_plugin( source_placeholder, "PollPlugin", version.content.language, poll=poll ) - + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" dt = datetime(2016, 6, 6) with freeze_time(dt): endpoint = self.get_move_plugin_uri(plugin) @@ -197,6 +202,7 @@ def test_cut_plugin(self): plugin = add_plugin( placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -223,6 +229,7 @@ def test_move_plugin(self): plugin = add_plugin( source_placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): diff --git a/tests/test_indicators.py b/tests/test_indicators.py index dabce266..f3601bed 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -11,6 +11,7 @@ BlogPostVersionFactory, PageFactory, PageVersionFactory, + TreeNode, ) @@ -86,7 +87,7 @@ def test_latest_admin_viewable_archive_on_top_of_published(self): class TestVersionState(CMSTestCase): def test_page_indicators(self): """The page content indicators render correctly""" - page = PageFactory(node__depth=1) + page = PageFactory(node__depth=1) if TreeNode else PageFactory(depth=1) version1 = PageVersionFactory( content__page=page, content__language="en", @@ -219,3 +220,13 @@ def test_mixin_factory(self): self.assertContains(response, "cms.pagetree.css"), # JS loadeD? self.assertContains(response, "indicators.js") + + def test_page_indicator_db_queries(self): + """Only one query should be executed to get the indicator""" + version = PageVersionFactory( + content__language="en", + ) + with self.assertNumQueries(1): + from djangocms_versioning.indicators import content_indicator + + content_indicator(version.content) diff --git a/tests/test_integration_with_core.py b/tests/test_integration_with_core.py index f414e6b8..ff66110e 100644 --- a/tests/test_integration_with_core.py +++ b/tests/test_integration_with_core.py @@ -1,7 +1,12 @@ +from unittest import skipIf + +from cms import __version__ as cms_version from cms.test_utils.testcases import CMSTestCase from cms.toolbar.toolbar import CMSToolbar from cms.utils.urlutils import admin_reverse +from django.template import Context +from djangocms_versioning import constants from djangocms_versioning.plugin_rendering import VersionContentRenderer from djangocms_versioning.test_utils.factories import ( PageFactory, @@ -9,6 +14,7 @@ PlaceholderFactory, PollVersionFactory, TextPluginFactory, + TreeNode, ) @@ -190,7 +196,7 @@ def test_default_cms_page_changelist_view_language_with_multi_language_content(s language filters / additional grouping values are set using the default CMS PageContent view """ - page = PageFactory(node__depth=1) + page = PageFactory(node__depth=1) if TreeNode else PageFactory(depth=1) en_version1 = PageVersionFactory( content__page=page, content__language="en", @@ -221,7 +227,7 @@ class WizzardTestCase(CMSTestCase): def test_success_url_for_cms_wizard(self): from cms.cms_wizards import cms_page_wizard, cms_subpage_wizard - from cms.toolbar.utils import get_object_preview_url + from cms.toolbar.utils import get_object_edit_url, get_object_preview_url from djangocms_versioning.test_utils.polls.cms_wizards import ( poll_wizard, @@ -229,21 +235,24 @@ def test_success_url_for_cms_wizard(self): # Test against page creations in different languages. version = PageVersionFactory(content__language="en") - self.assertEqual( + self.assertIn( cms_page_wizard.get_success_url(version.content.page, language="en"), - get_object_preview_url(version.content), + [get_object_preview_url(version.content), get_object_edit_url(version.content)], ) version = PageVersionFactory(content__language="en") - self.assertEqual( + self.assertIn( cms_subpage_wizard.get_success_url(version.content.page, language="en"), - get_object_preview_url(version.content), + [get_object_preview_url(version.content), get_object_edit_url(version.content)], ) version = PageVersionFactory(content__language="de") - self.assertEqual( + self.assertIn( cms_page_wizard.get_success_url(version.content.page, language="de"), - get_object_preview_url(version.content, language="de"), + [ + get_object_preview_url(version.content, language="de"), + get_object_edit_url(version.content, language="de") + ], ) # Test against a model that doesn't have a PlaceholderRelationField @@ -252,3 +261,43 @@ def test_success_url_for_cms_wizard(self): poll_wizard.get_success_url(version.content), version.content.get_absolute_url(), ) + + +class AdminManagerIntegrationTestCase(CMSTestCase): + def setUp(self): + self.page = PageFactory(node__depth=1) if TreeNode else PageFactory(depth=1) + self.en_version = PageVersionFactory( + content__page=self.page, + content__language="en", + state=constants.UNPUBLISHED, + ) + self.fr_version = PageVersionFactory( + content__page=self.page, + content__language="fr", + state=constants.ARCHIVED, + ) + self.page.languages = "en,fr" + self.page.save() + + + @skipIf(cms_version < "4.1.4", "Bug only fixed in django CMS 4.1.4") + def test_get_admin_url_for_language(self): + """Regression fixed that made unpublished and archived versions invisible to get_admin_url_for_language + template tag. See: https://github.com/django-cms/django-cms/pull/7967""" + from django.template import Template + + # Test English page with unpublished version + context = Context({"page": self.page}) + template = Template("{% load cms_admin %}{% get_admin_url_for_language page 'en' %}") + + result = template.render(context) + + self.assertIn(f"/admin/cms/pagecontent/{self.en_version.content.pk}/", result) + + # Test French page with archived version + template = Template("{% load cms_admin %}{% get_admin_url_for_language page 'fr' %}") + + result = template.render(context) + + self.assertIn(f"/admin/cms/pagecontent/{self.fr_version.content.pk}/", result) + diff --git a/tests/test_locking.py b/tests/test_locking.py index 47d5e21d..f8199f57 100644 --- a/tests/test_locking.py +++ b/tests/test_locking.py @@ -270,11 +270,13 @@ def test_unlock_link_not_present_for_user_with_no_unlock_privileges(self): locked_by=self.user_author) changelist_url = version_list_url(poll_version.content) unlock_url = self.get_admin_url(self.versionable.version_model_proxy, "unlock", poll_version.pk) - + exprected_disabled_button = ( + f'' + ) with self.login_user_context(self.user_has_no_unlock_perms): response = self.client.post(changelist_url) - - self.assertNotContains(response, unlock_url) + self.assertInHTML(exprected_disabled_button, response.content.decode("utf-8")) def test_unlock_link_present_for_user_with_privileges(self): poll_version = factories.PollVersionFactory( @@ -392,11 +394,12 @@ def test_edit_action_link_disabled_state(self): author_request.user = self.user_author otheruser_request = RequestFactory() otheruser_request.user = self.superuser + expected_disabled_state = "" actual_disabled_state = self.version_admin._get_edit_link(version, otheruser_request) self.assertFalse(version.check_edit_redirect.as_bool(self.superuser)) - self.assertEqual("", actual_disabled_state) + self.assertEqual(expected_disabled_state, actual_disabled_state) @override_settings(DJANGOCMS_VERSIONING_LOCK_VERSIONS=True) @@ -647,7 +650,7 @@ def test_version_is_unlocked_for_publishing(self): """ A version lock is not present when a content version is in a published or unpublished state """ - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() poll_version = factories.PollVersionFactory(state=DRAFT, created_by=user, locked_by=user) publish_url = self.get_admin_url(self.versionable.version_model_proxy, "publish", poll_version.pk) unpublish_url = self.get_admin_url(self.versionable.version_model_proxy, "unpublish", poll_version.pk) @@ -749,7 +752,7 @@ def setUp(self) -> None: self.user_has_change_perms = self._create_user( "user_default_perms", is_staff=True, - permissions=["change_page", "add_page", "publish_page", "delete_page"], + permissions=["change_page", "add_page", "delete_page"], ) # Grant permission (or Unlock button will not be shown) GlobalPagePermission.objects.create( @@ -836,7 +839,7 @@ def test_enable_edit_button_when_content_is_locked(self): self.assertFalse(edit_button.disabled) self.assertListEqual( edit_button.extra_classes, - ["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-edit-btn"] + ["cms-btn-action", "cms-form-post-method", "cms-versioning-js-edit-btn"] ) def test_lock_message_when_content_is_locked(self): diff --git a/tests/test_menus.py b/tests/test_menus.py index a8c02b1a..5340d82d 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -19,48 +19,31 @@ class CMSVersionedMenuTestCase(CMSTestCase): def setUp(self): super().setUp() - self._page_1 = PageVersionFactory( - content__title="page_content_1", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="0001", - ) - self._page_2 = PageVersionFactory( - content__title="page_content_2", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="0002", - ) - self._page_2_1 = PageVersionFactory( - content__title="page_content_2_1", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="00020001", - content__page__node__parent=self._page_2.content.page.node, - ) - self._page_2_2 = PageVersionFactory( - content__title="page_content_2_2", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="00020002", - content__page__node__parent=self._page_2.content.page.node, - ) - self._page_3 = PageVersionFactory( - content__title="page_content_3", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="0003", - ) + from djangocms_versioning.test_utils.factories import TreeNode + + def get_page(title, path, parent=None): + return { + "content__title": title, + "content__menu_title": "", + "content__in_navigation": True, + "content__limit_visibility_in_menu": None, + "content__language": "en", + "content__page__node__path" if TreeNode else "content__page__path": path, + "content__page__node__parent" if TreeNode else "content__page__parent": parent, + } + self._page_1 = PageVersionFactory(**get_page("page_content_1", "0001")) + self._page_2 = PageVersionFactory(**get_page("page_content_2", "0002")) + self._page_2_1 = PageVersionFactory(**get_page( + "page_content_2_1", + "00020001", + self._page_2.content.page.node if TreeNode else self._page_2.content.page, + )) + self._page_2_2 = PageVersionFactory(**get_page( + "page_content_2_2", + "00020002", + self._page_2.content.page.node if TreeNode else self._page_2.content.page, + )) + self._page_3 = PageVersionFactory(**get_page("page_content_3", "0003")) def _render_menu(self, user=None, **kwargs): request = RequestFactory().get("/") diff --git a/tests/test_models.py b/tests/test_models.py index 98ac5b3e..8485d9ec 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,6 +12,10 @@ from djangocms_versioning.test_utils.polls.cms_config import PollsCMSConfig from djangocms_versioning.test_utils.polls.models import Poll, PollContent +if not hasattr(CMSTestCase, "assertQuerySetEqual"): + # Django < 4.2 + CMSTestCase.assertQuerySetEqual = CMSTestCase.assertQuerysetEqual + class CopyTestCase(CMSTestCase): def _create_versionables_mock(self, copy_function): @@ -261,7 +265,7 @@ def test_filter_by_grouper(self): versions_for_grouper = Version.objects.filter_by_grouper(poll) - self.assertQuerysetEqual( + self.assertQuerySetEqual( versions_for_grouper, [versions[0].pk, versions[1].pk], transform=lambda o: o.pk, @@ -278,7 +282,7 @@ def test_filter_by_grouper_doesnt_include_other_content_types(self): versions_for_grouper = Version.objects.filter_by_grouper(pv.content.poll) # Only poll version included - self.assertQuerysetEqual( + self.assertQuerySetEqual( versions_for_grouper, [pv.pk], transform=lambda o: o.pk, ordered=False ) diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 00000000..5dc74a35 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,259 @@ +from unittest.mock import patch + +from django.core.checks import messages + +from djangocms_versioning import constants +from djangocms_versioning.models import StateTracking, Version +from djangocms_versioning.test_utils import factories +from djangocms_versioning.test_utils.blogpost.cms_config import BlogpostCMSConfig +from djangocms_versioning.test_utils.polls.cms_config import PollsCMSConfig +from tests.test_admin import BaseStateTestCase + + +class PermissionTestCase(BaseStateTestCase): + def setUp(self): + self.versionable = BlogpostCMSConfig.versioning[0] + self.poll_versionable = PollsCMSConfig.versioning[0] + + def get_user(self, username, is_staff=True): + user = factories.UserFactory(username=username, is_staff=is_staff) + user.set_password(username) + user.save() + return user + + @patch("django.contrib.messages.add_message") + def test_publish_view_cannot_be_accessed_without_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", post_version.pk + ) + + with self.login_user_context(self.get_staff_user_with_no_permissions()): + response = self.client.post(url) + + self.assertRedirectsToPreview(response, post_version) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.DRAFT) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_unpublish_view_cannot_be_accessed_without_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.PUBLISHED) + url = self.get_admin_url( + self.versionable.version_model_proxy, "unpublish", post_version.pk + ) + + with self.login_user_context(self.get_staff_user_with_no_permissions()): + response = self.client.post(url) + + self.assertRedirectsToPreview(response, post_version) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.PUBLISHED) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + + @patch("django.contrib.messages.add_message") + def test_publish_view_can_be_accessed_with_low_level_permission( + self, mocked_messages + ): + # alice has no permission to publish bob's post + post_version = factories.BlogPostVersionFactory(state=constants.DRAFT, content__text="bob's post") + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", post_version.pk + ) + + with self.login_user_context(self.get_user("bob")): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.INFO) + self.assertEqual(mocked_messages.call_args[0][2], "Version published") + + # status has changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.PUBLISHED) + # status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 1) + + @patch("django.contrib.messages.add_message") + def test_publish_view_cannot_be_accessed_wo_low_level_permission( + self, mocked_messages + ): + # alice has no permission to publish bob's post + post_version = factories.BlogPostVersionFactory(state=constants.DRAFT, content__text="bob's post") + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", post_version.pk + ) + + with self.login_user_context(self.get_user("alice")): + response = self.client.post(url) + + self.assertRedirectsToPreview(response, post_version) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.DRAFT) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_unpublish_view_can_be_accessed_with_low_level_permission( + self, mocked_messages + ): + # bob has permission to unpublish bob's post + post_version = factories.BlogPostVersionFactory(state=constants.PUBLISHED, content__text="bob's post") + url = self.get_admin_url( + self.versionable.version_model_proxy, "unpublish", post_version.pk + ) + + with self.login_user_context(self.get_user("bob")): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.INFO) + self.assertEqual(mocked_messages.call_args[0][2], "Version unpublished") + + # status has changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.UNPUBLISHED) + # status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 1) + + @patch("django.contrib.messages.add_message") + def test_unpublish_view_cannot_be_accessed_wo_low_level_permission( + self, mocked_messages + ): + # alice has no permission to unpublish bob's post + post_version = factories.BlogPostVersionFactory(state=constants.PUBLISHED, content__text="bob's post") + url = self.get_admin_url( + self.versionable.version_model_proxy, "unpublish", post_version.pk + ) + + with self.login_user_context(self.get_user("alice")): + response = self.client.post(url) + + self.assertRedirectsToPreview(response, post_version) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.PUBLISHED) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_archive_view_cannot_be_accessed_without_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "archive", post_version.pk + ) + user = self.get_staff_user_with_no_permissions() + + with self.login_user_context(user): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.DRAFT) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_archive_view_can_be_accessed_with_permission( + self, mocked_messages + ): + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.poll_versionable.version_model_proxy, "archive", poll_version.pk + ) + user = self.get_staff_user_with_no_permissions() + user.user_permissions.add(self.get_permission("change_pollcontent")) + + with self.login_user_context(user): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.INFO) + self.assertEqual(mocked_messages.call_args[0][2], "Version archived") + + # status has changed + poll_version_ = Version.objects.get(pk=poll_version.pk) + self.assertEqual(poll_version_.state, constants.ARCHIVED) + # status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 1) + + @patch("django.contrib.messages.add_message") + def test_revert_view_cannot_be_accessed_without_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.ARCHIVED) + url = self.get_admin_url( + self.versionable.version_model_proxy, "revert", post_version.pk + ) + user = self.get_staff_user_with_no_permissions() + + with self.login_user_context(user): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + poll_version_ = Version.objects.get(pk=post_version.pk) + self.assertEqual(poll_version_.state, constants.ARCHIVED) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_revert_view_can_be_accessed_with_low_level_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.ARCHIVED, content__text="post ") + url = self.get_admin_url( + self.versionable.version_model_proxy, "revert", post_version.pk + ) + user = self.get_user("alice", is_staff=True) + with self.login_user_context(user): + self.client.post(url) + + # new draft has been created + post_version_ = Version.objects.filter( + content_type=post_version.content_type, + object_id__gt=post_version.object_id, + pk__gt=post_version.pk + ).first() + self.assertIsNotNone(post_version_) + self.assertEqual(post_version_.state, constants.DRAFT) + self.assertTrue(post_version_.content.has_change_permission(user)) # Content was copied diff --git a/tests/test_signals.py b/tests/test_signals.py index 47b24ec2..3d5d25f9 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -48,6 +48,31 @@ def test_publish_signals_fired(self): ) self.assertEqual(post_call_kwargs["obj"], version) + + def test_publish_signals_fired_with_to_be_published_and_unpublished(self): + poll = factories.PollFactory() + version1 = factories.PollVersionFactory( + state=constants.DRAFT, content__poll=poll + ) + version2 = version1.copy(self.superuser) + + # Here, we just expect the signals for version 1 + with signal_tester(pre_version_operation, post_version_operation) as env: + version1.publish(self.superuser) + self.assertEqual(env.call_count, 2) + + # Here, we expect the signals for the unpublish of version 1 and the + # publish of version 2. + with signal_tester(pre_version_operation, post_version_operation) as env: + version2.publish(self.superuser) + self.assertEqual(env.call_count, 4) + version_1_pre_call_kwargs = env.calls[1][1] + version_2_post_call_kwargs = env.calls[3][1] + + self.assertEqual(version_1_pre_call_kwargs["to_be_published"], version2) + self.assertEqual(version_2_post_call_kwargs["unpublished"], [version1]) + + def test_unpublish_signals_fired(self): """ When a version is changed to unpublished the correct signals are fired! diff --git a/tests/test_toolbars.py b/tests/test_toolbars.py index a98d7aba..e4674560 100644 --- a/tests/test_toolbars.py +++ b/tests/test_toolbars.py @@ -6,12 +6,14 @@ from django.utils.text import slugify from djangocms_versioning.cms_config import VersioningCMSConfig +from djangocms_versioning.cms_toolbars import VersioningPageToolbar from djangocms_versioning.constants import ARCHIVED, DRAFT, PUBLISHED from djangocms_versioning.helpers import version_list_url from djangocms_versioning.test_utils.factories import ( BlogPostVersionFactory, FancyPollFactory, PageContentWithVersionFactory, + PageFactory, PageUrlFactory, PageVersionFactory, PollVersionFactory, @@ -65,7 +67,7 @@ def test_publish_in_toolbar_in_edit_mode(self): self.assertFalse(publish_button.disabled) self.assertListEqual( publish_button.extra_classes, - ["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-publish-btn"], + ["cms-btn-action", "cms-form-post-method", "cms-versioning-js-publish-btn"], ) def test_revert_in_toolbar_in_preview_mode(self): @@ -73,7 +75,7 @@ def test_revert_in_toolbar_in_preview_mode(self): version = PollVersionFactory() version.archive(self.get_superuser()) - toolbar = get_toolbar(version.content, edit_mode=False) + toolbar = get_toolbar(version.content, edit_mode=False, user=self.get_superuser()) toolbar.post_template_populate() publish_button = find_toolbar_buttons("Publish", toolbar.toolbar) @@ -150,7 +152,7 @@ def test_edit_in_toolbar_in_preview_mode(self): self.assertFalse(edit_button.disabled) self.assertListEqual( edit_button.extra_classes, - ["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-edit-btn"] + ["cms-btn-action", "cms-form-post-method", "cms-versioning-js-edit-btn"] ) def test_edit_not_in_toolbar_in_edit_mode(self): @@ -211,10 +213,11 @@ def test_default_cms_edit_button_is_replaced_by_versioning_edit_button(self): The versioning edit button is available on the toolbar when versioning is installed and the model is versionable. """ - pagecontent = PageVersionFactory(content__template="") - url = get_object_preview_url(pagecontent.content, language="en") + page = PageVersionFactory(content__template="", content__language="en") + url = get_object_preview_url(page.content) + edit_url = self._get_edit_url( - pagecontent, VersioningCMSConfig.versioning[0] + page, VersioningCMSConfig.versioning[0] ) with self.login_user_context(self.get_superuser()): @@ -340,6 +343,13 @@ def test_view_published_in_toolbar_in_edit_mode_for_published_page(self): are published """ published_version = PageVersionFactory(content__language="en", state=PUBLISHED) + # Create URL + PageUrlFactory( + page=published_version.content.page, + language=published_version.content.language, + path=slugify("test_page"), + slug=slugify("test_page"), + ) toolbar = get_toolbar(published_version.content, edit_mode=True) toolbar.post_template_populate() @@ -352,6 +362,13 @@ def test_view_published_in_toolbar_in_preview_mode_for_published_page(self): are published """ published_version = PageVersionFactory(content__language="en", state=PUBLISHED) + # Create URL + PageUrlFactory( + page=published_version.content.page, + language=published_version.content.language, + path=slugify("test_page"), + slug=slugify("test_page"), + ) toolbar = get_toolbar(published_version.content, preview_mode=True) toolbar.post_template_populate() @@ -600,3 +617,18 @@ def test_page_toolbar_wo_language_menu(self): language_menu = request.toolbar.get_menu(LANGUAGE_MENU_IDENTIFIER, _("Language")) self.assertIsNone(language_menu) + + def test_toolbar_only_catches_page_content_objects(self): + """Regression test to ensure that the toolbar only catches PageContent objects and not + other toolbar objects.""" + + version = PollVersionFactory() # Not a page content model + page = PageFactory() # Get a page, e.g. where an apphook is configured + toolbar = get_toolbar(version.content, edit_mode=True, toolbar_class=VersioningPageToolbar, current_page=page) + + # Did page get detected? Otherwise, page_content never will be detected + self.assertIs(toolbar.page, page) + # Check regression does not happen + self.assertNotIsInstance(toolbar.page_content, version.content.__class__) + # Check for correct result + self.assertIsNone(toolbar.page_content) diff --git a/tox.ini b/tox.ini index 36dad075..52c40c51 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = - flake8 - isort - py{39.310,311}-dj{32,40,41}-sqlite + ruff + py{39.310,311}-dj{32,40,41,42}-sqlite + py{311,312}-djmain-cms-develop4-sqlite skip_missing_interpreters=True @@ -13,11 +13,14 @@ deps = dj32: -r{toxinidir}/tests/requirements/dj32_cms41.txt dj40: -r{toxinidir}/tests/requirements/dj40_cms41.txt dj41: -r{toxinidir}/tests/requirements/dj41_cms41.txt + dj42: -r{toxinidir}/tests/requirements/dj42_cms41.txt + djmain: https://github.com/django/django/archive/main.tar.gz + develop4: https://github.com/django-cms/django-cms/archive/develop-4.tar.gz basepython = py39: python3.9 py310: python3.10 - py311: python3.10 + py311: python3.11 commands = {envpython} --version @@ -25,10 +28,9 @@ commands = {env:COMMAND:coverage} run setup.py test {env:COMMAND:coverage} report -[testenv:flake8] -commands = flake8 -basepython = python3.9 +[testenv:ruff] +commands = + ruff {toxinidir}/djangocms_versioning + ruff {toxinidir}/tests -[testenv:isort] -commands = isort --check-only --diff {toxinidir}/djangocms_versioning -basepython = python3.9 +basepython = python3.11