From b6f4b4ad1f9c115fa32a6eb6d5a95c7b5acb40f9 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 12:07:50 +0100 Subject: [PATCH 01/13] feat: add management command --- .../management/commands/__init__.py | 0 .../management/commands/create_versions.py | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 djangocms_versioning/management/commands/__init__.py create mode 100644 djangocms_versioning/management/commands/create_versions.py diff --git a/djangocms_versioning/management/commands/__init__.py b/djangocms_versioning/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/djangocms_versioning/management/commands/create_versions.py b/djangocms_versioning/management/commands/create_versions.py new file mode 100644 index 00000000..63bada4f --- /dev/null +++ b/djangocms_versioning/management/commands/create_versions.py @@ -0,0 +1,28 @@ +from django.core.management.base import BaseCommand, CommandError + +from djangocms_versioning import constants +from djangocms_versioning.models import Version +from djangocms_versioning.versionables import _cms_extension + + +# from polls.models import Question as Poll + +class Command(BaseCommand): + help = 'Creates Version objects for versioned models lacking one' + + def add_arguments(self, parser): + parser.add_argument("state", type=str, default=constants.DRAFT) + + def get_user(self, options): + pass + + def handle(self, *args, **options): + if options["state"] not in dict(constants.VERSION_STATES): + raise CommandError(f"state needs to be one of {', '.join(key for key, value in constants.VERSION_STATES)}") + user = self.get_user(options) + versionables = _cms_extension().versionables + print(f"{versionables=}") + for model in versionables: + pass + + self.stdout.write(self.style.SUCCESS('Successfully closed poll "%s"' % 1)) From 090122aeeb57b5c599c33d0b0249e1f1ef8e3d11 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 15:25:00 +0100 Subject: [PATCH 02/13] feat: create version objects if adding versioning to existing models --- djangocms_versioning/conf.py | 4 + .../management/commands/create_versions.py | 88 ++++++++++++++++--- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/djangocms_versioning/conf.py b/djangocms_versioning/conf.py index 5999d5fe..6ed9f68c 100644 --- a/djangocms_versioning/conf.py +++ b/djangocms_versioning/conf.py @@ -8,3 +8,7 @@ USERNAME_FIELD = getattr( settings, "DJANGOCMS_VERSIONING_USERNAME_FIELD", 'username' ) + +DEFAULT_USER = getattr( + settings, "DJANGOCMS_VERSIONING_DEFAULT_USER", None +) diff --git a/djangocms_versioning/management/commands/create_versions.py b/djangocms_versioning/management/commands/create_versions.py index 63bada4f..74026578 100644 --- a/djangocms_versioning/management/commands/create_versions.py +++ b/djangocms_versioning/management/commands/create_versions.py @@ -1,28 +1,96 @@ +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand, CommandError from djangocms_versioning import constants +from djangocms_versioning.conf import DEFAULT_USER, USERNAME_FIELD from djangocms_versioning.models import Version from djangocms_versioning.versionables import _cms_extension -# from polls.models import Question as Poll +User = get_user_model() + class Command(BaseCommand): help = 'Creates Version objects for versioned models lacking one' def add_arguments(self, parser): - parser.add_argument("state", type=str, default=constants.DRAFT) + parser.add_argument( + "--state", + type=str, + default=constants.DRAFT, + choices=[key for key, value in constants.VERSION_STATES], + help="state of newly created version object" + ) + parser.add_argument( + "--username", + type=str, + nargs="?", + help="Username of user to create the missing Version objects" + ) + parser.add_argument( + "--userid", + type=int, + nargs="?", + help="User id of user to create the missing Version objects" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Do not change the database", + ) def get_user(self, options): - pass + user = None + if options["userid"] and options["username"]: + raise CommandError("Only either one of the options '--userid' or '--username' may be given") + if options["userid"]: + try: + user = User.objects.get(pk=options["userid"]) + except User.DoesNotExist: + raise CommandError(f"No user with id {options['userid']} found") + if options["username"]: + try: + user = User.objects.get(**{USERNAME_FIELD: options["username"]}) + except User.DoesNotExist: + raise CommandError(f"No user with name {options['username']} found") + if user is None and DEFAULT_USER is not None: + try: + user = User.objects.get(pk=DEFAULT_USER) + except User.DoesNotExist: + raise CommandError(f"No user with id {DEFAULT_USER} found " + f"(specified as DJANGOCMS_VERSIONING_DEFAULT USER in settings.py") + return user def handle(self, *args, **options): - if options["state"] not in dict(constants.VERSION_STATES): - raise CommandError(f"state needs to be one of {', '.join(key for key, value in constants.VERSION_STATES)}") user = self.get_user(options) - versionables = _cms_extension().versionables - print(f"{versionables=}") - for model in versionables: - pass - self.stdout.write(self.style.SUCCESS('Successfully closed poll "%s"' % 1)) + for versionable in _cms_extension().versionables: + Model = versionable.content_model + content_type = ContentType.objects.get_for_model(Model) + version_ids = Version.objects.filter(content_type_id=content_type.pk).values_list("object_id", flat=True) + unversioned = Model.admin_manager.exclude(pk__in=version_ids).order_by("-pk") + self.stdout.write(self.style.NOTICE( + f"{len(version_ids) + len(unversioned)} objects of type {Model.__name__}, thereof " + f"{len(unversioned)} missing Version object" + )) + if not options["dry_run"]: + if user is None: + raise CommandError("Please specify a user which missing Version objects shall belong to " + "either with the DJANGOCMS_VERSIONING_DEFAULT_USER setting or using " + "command line arguments") + for orphan in unversioned: + try: + Version.objects.create( + content=orphan, + state=options["state"], + created_by=user, + ) + self.stdout.write(self.style.SUCCESS( + f"Successfully created version object for {Model.__name__} with pk={orphan.pk}" + )) + except Exception as e: + self.stdout.write(self.style.ERROR( + f"Failed creating version object for {Model.__name__} with pk={orphan.pk}: {e}" + )) From f94f1bfc5c0b0cb7eb6bac962cee834022b9012d Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 15:43:34 +0100 Subject: [PATCH 03/13] Clarify help text. Change precedence of user selection --- .../management/commands/create_versions.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/djangocms_versioning/management/commands/create_versions.py b/djangocms_versioning/management/commands/create_versions.py index 74026578..21ae3659 100644 --- a/djangocms_versioning/management/commands/create_versions.py +++ b/djangocms_versioning/management/commands/create_versions.py @@ -12,7 +12,9 @@ class Command(BaseCommand): - help = 'Creates Version objects for versioned models lacking one' + help = 'Creates Version objects for versioned models lacking one. If the DJANGOCMS_VERSIONING_DEFAULT_USER ' \ + 'setting is not populated you will have to provide either the --userid or --username option for ' \ + 'each Version object needs to be assigned to a user.' def add_arguments(self, parser): parser.add_argument( @@ -20,18 +22,16 @@ def add_arguments(self, parser): type=str, default=constants.DRAFT, choices=[key for key, value in constants.VERSION_STATES], - help="state of newly created version object" + help=f"state of newly created version object (defaults to {constants.DRAFT})" ) parser.add_argument( "--username", type=str, - nargs="?", help="Username of user to create the missing Version objects" ) parser.add_argument( "--userid", type=int, - nargs="?", help="User id of user to create the missing Version objects" ) @@ -42,26 +42,26 @@ def add_arguments(self, parser): ) def get_user(self, options): - user = None + if DEFAULT_USER is not None: + try: + return User.objects.get(pk=DEFAULT_USER) + except User.DoesNotExist: + raise CommandError(f"No user with id {DEFAULT_USER} found " + f"(specified as DJANGOCMS_VERSIONING_DEFAULT USER in settings.py") + if options["userid"] and options["username"]: raise CommandError("Only either one of the options '--userid' or '--username' may be given") if options["userid"]: try: - user = User.objects.get(pk=options["userid"]) + return User.objects.get(pk=options["userid"]) except User.DoesNotExist: raise CommandError(f"No user with id {options['userid']} found") if options["username"]: try: - user = User.objects.get(**{USERNAME_FIELD: options["username"]}) + return User.objects.get(**{USERNAME_FIELD: options["username"]}) except User.DoesNotExist: raise CommandError(f"No user with name {options['username']} found") - if user is None and DEFAULT_USER is not None: - try: - user = User.objects.get(pk=DEFAULT_USER) - except User.DoesNotExist: - raise CommandError(f"No user with id {DEFAULT_USER} found " - f"(specified as DJANGOCMS_VERSIONING_DEFAULT USER in settings.py") - return user + return None def handle(self, *args, **options): user = self.get_user(options) From f29e98aee0dc4a4eda95c3a3c3bd14e604de7b58 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 15:55:27 +0100 Subject: [PATCH 04/13] Add: documentation --- docs/{ => api}/advanced_configuration.rst | 0 docs/{ => api}/customizing_version_list.rst | 0 docs/api/management_commands.rst | 44 +++++++++++++++++++++ docs/{ => api}/signals.rst | 0 docs/index.rst | 7 ++-- 5 files changed, 48 insertions(+), 3 deletions(-) rename docs/{ => api}/advanced_configuration.rst (100%) rename docs/{ => api}/customizing_version_list.rst (100%) create mode 100644 docs/api/management_commands.rst rename docs/{ => api}/signals.rst (100%) diff --git a/docs/advanced_configuration.rst b/docs/api/advanced_configuration.rst similarity index 100% rename from docs/advanced_configuration.rst rename to docs/api/advanced_configuration.rst diff --git a/docs/customizing_version_list.rst b/docs/api/customizing_version_list.rst similarity index 100% rename from docs/customizing_version_list.rst rename to docs/api/customizing_version_list.rst diff --git a/docs/api/management_commands.rst b/docs/api/management_commands.rst new file mode 100644 index 00000000..913f6079 --- /dev/null +++ b/docs/api/management_commands.rst @@ -0,0 +1,44 @@ +Management command +================== + +create_versions +--------------- + +``create_versions`` creates ``Version`` objects for versioned content that does +not have a version assigned. This happens if djangocms-versioning is added to +content models after content already has been created. + +By default, the existing content is assigned the draft status. + +Each version is assigned a user who created the version. When this command is +run, either + +* the user is taken from the ``DJANGOCMS_VERSIONING_DEFAULT_USER`` setting + which must contain the primary key (pk) of the user, or +* one of the options ``--userid`` or ``--username`` + +If ``DJANGOCMS_VERSIONING_DEFAULT_USER`` is set it cannot be overridden by a +command line option. + +.. code-block:: shell + + usage: manage.py create_versions [-h] + [--state {draft,published,unpublished,archived}] + [--username USERNAME] [--userid USERID] + [--dry-run] + + Creates Version objects for versioned models lacking one. If the + DJANGOCMS_VERSIONING_DEFAULT_USER setting is not populated you + will have to provide either the --userid or --username option for + each Version object needs to be assigned to a user. + + optional arguments: + -h, --help show this help message and exit + --state {draft,published,unpublished,archived} + state of newly created version object + (defaults to draft) + --username USERNAME Username of user to create the missing + Version objects + --userid USERID User id of user to create the missing + Version objects + --dry-run Do not change the database diff --git a/docs/signals.rst b/docs/api/signals.rst similarity index 100% rename from docs/signals.rst rename to docs/api/signals.rst diff --git a/docs/index.rst b/docs/index.rst index 05f3a756..a33563db 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,9 +11,10 @@ Welcome to "djangocms-versioning"'s documentation! :maxdepth: 2 :caption: API Reference: - advanced_configuration - signals - customizing_version_list + api/advanced_configuration + api/signals + api/customizing_version_list + api/management_commands .. toctree:: :maxdepth: 2 From 9b36cfa3705f9a9f5d8c7859484ab28aade6807c Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 18:04:39 +0100 Subject: [PATCH 05/13] Add: release notes and breaking changes --- CHANGELOG.rst | 1 + docs/index.rst | 6 ++++++ docs/upgrade/2.0.0.rst | 44 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 docs/upgrade/2.0.0.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aebe170a..27b195ea 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,7 @@ Unreleased * add: Revert button as replacement for dysfunctional Edit button for unpublished versions * add: status indicators and drop down menus for django cms page tree +* add BREAKING: use icons provided by cms core version 4.1+ * 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 diff --git a/docs/index.rst b/docs/index.rst index a33563db..28024ca3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,6 +22,12 @@ Welcome to "djangocms-versioning"'s documentation! admin_architecture +.. toctree:: + :maxdepth: 2 + :caption: Release notes: + + upgrade/2.0.0 + Glossary -------- diff --git a/docs/upgrade/2.0.0.rst b/docs/upgrade/2.0.0.rst new file mode 100644 index 00000000..e33c2a4c --- /dev/null +++ b/docs/upgrade/2.0.0.rst @@ -0,0 +1,44 @@ +.. _upgrade-to-2-0-0: + +******************************** +2.0.0 release notes (unreleased) +******************************** + +*Date in 2023* + +Welcome to django CMS versioning 2.0.0! + +These release notes cover the new features, as well as some backwards +incompatible changes you’ll want to be aware of when upgrading from +django CMS versioning 1.x. + + +Django and Python compatibility +=============================== + +django CMS supports **Django 3.2, 4.0, and 4.1**. We highly recommend and only +support the latest release of each series. + +It supports **Python 3.8, 3.9, 3.10, and 3.11**. As for Django we highly recommend and only +support the latest release of each series. + +Backwards incompatible changes in 2.0.0 +======================================= + +Monkey patching +--------------- + +* Version 2.0.0 uses new configuration possibilities of django CMS 4.1+ and + therefor is incompatible with versions 4.0.x +* As a result monkey patching has been removed from djangocms-versioning and + is discouraged + +Status indicators in page tree +------------------------------ + +* Status indicators are shown in the page tree as of django CMS 4.1+ +* For a more consistent user experience djangocms-versioning uses icons + provided by django CMS 4.1+ and does not provide its own icons any more. +* If ``djangocms_admin_style`` is listed in the ``INSTALLED_APPS`` setting + make sure that at least version 3.2.1 is installed. Older versions contain + a bug that interferes with djangocms-versioning's icons. From f6339b0a2ec8768f8f4c64ead3c7fc14410e1f0d Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 20:04:49 +0100 Subject: [PATCH 06/13] feat: add recovery logic --- .../management/commands/create_versions.py | 45 ++++++++++++++----- docs/api/management_commands.rst | 27 ++++++----- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/djangocms_versioning/management/commands/create_versions.py b/djangocms_versioning/management/commands/create_versions.py index 21ae3659..5558bd45 100644 --- a/djangocms_versioning/management/commands/create_versions.py +++ b/djangocms_versioning/management/commands/create_versions.py @@ -14,14 +14,16 @@ class Command(BaseCommand): help = 'Creates Version objects for versioned models lacking one. If the DJANGOCMS_VERSIONING_DEFAULT_USER ' \ 'setting is not populated you will have to provide either the --userid or --username option for ' \ - 'each Version object needs to be assigned to a user.' + 'each Version object needs to be assigned to a user. ' \ + 'If multiple content objects for a grouper model are found only the newest (by primary key) is ' \ + 'assigned the state, older versions are marked as "archived".' def add_arguments(self, parser): parser.add_argument( "--state", type=str, default=constants.DRAFT, - choices=[key for key, value in constants.VERSION_STATES], + choices=[key for key, value in constants.VERSION_STATES if key != constants.UNPUBLISHED], help=f"state of newly created version object (defaults to {constants.DRAFT})" ) parser.add_argument( @@ -41,7 +43,8 @@ def add_arguments(self, parser): help="Do not change the database", ) - def get_user(self, options): + @staticmethod + def get_user(options): if DEFAULT_USER is not None: try: return User.objects.get(pk=DEFAULT_USER) @@ -75,16 +78,38 @@ def handle(self, *args, **options): f"{len(version_ids) + len(unversioned)} objects of type {Model.__name__}, thereof " f"{len(unversioned)} missing Version object" )) - if not options["dry_run"]: - if user is None: - raise CommandError("Please specify a user which missing Version objects shall belong to " - "either with the DJANGOCMS_VERSIONING_DEFAULT_USER setting or using " - "command line arguments") - for orphan in unversioned: + if user is None and not options["dry_run"] and unversioned: + raise CommandError("Please specify a user which missing Version objects shall belong to " + "either with the DJANGOCMS_VERSIONING_DEFAULT_USER setting or using " + "command line arguments") + + for orphan in unversioned: + # find all model instances that belong to the same grouper + selectors = {versionable.grouper_field_name: getattr(orphan, versionable.grouper_field_name)} + for extra_selector in versionable.extra_grouping_fields: + selectors[extra_selector] = getattr(orphan, extra_selector) + same_grouper_ids = Model.admin_manager.filter(**selectors).values_list("pk", flat=True) + # get all existing version objects + existing_versions = Version.objects.filter(content_type=content_type, object_id__in=same_grouper_ids) + # target state + state = options["state"] + # change to "archived" if state already exists + if state != constants.ARCHIVED: + for version in existing_versions: + if version.state == state: + state = constants.ARCHIVED + break + + if options["dry_run"]: + # Only write out change + self.stdout.write((self.style.NOTICE( + f"{str(orphan)} (pk={orphan.pk}) would be assigned a Version object with state {state}" + ))) + else: try: Version.objects.create( content=orphan, - state=options["state"], + state=state, created_by=user, ) self.stdout.write(self.style.SUCCESS( diff --git a/docs/api/management_commands.rst b/docs/api/management_commands.rst index 913f6079..a588c403 100644 --- a/docs/api/management_commands.rst +++ b/docs/api/management_commands.rst @@ -22,23 +22,22 @@ command line option. .. code-block:: shell - usage: manage.py create_versions [-h] - [--state {draft,published,unpublished,archived}] - [--username USERNAME] [--userid USERID] - [--dry-run] + usage: manage.py create_versions [-h] [--state {draft,published,archived}] + [--username USERNAME] [--userid USERID] [--dry-run] + [--version] [-v {0,1,2,3}] [--settings SETTINGS] + [--pythonpath PYTHONPATH] [--traceback] [--no-color] + [--force-color] [--skip-checks] Creates Version objects for versioned models lacking one. If the - DJANGOCMS_VERSIONING_DEFAULT_USER setting is not populated you - will have to provide either the --userid or --username option for - each Version object needs to be assigned to a user. + DJANGOCMS_VERSIONING_DEFAULT_USER setting is not populated you will have to provide + either the --userid or --username option for each Version object needs to be assigned + to a user. If multiple content objects for a grouper model are found only the newest + (by primary key) is assigned the state, older versions are marked as "archived". optional arguments: -h, --help show this help message and exit - --state {draft,published,unpublished,archived} - state of newly created version object - (defaults to draft) - --username USERNAME Username of user to create the missing - Version objects - --userid USERID User id of user to create the missing - Version objects + --state {draft,published,archived} + state of newly created version object (defaults to draft) + --username USERNAME Username of user to create the missing Version objects + --userid USERID User id of user to create the missing Version objects --dry-run Do not change the database From 3df04a7103706c7cac14cab149f4d56feb40740f Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 20:43:59 +0100 Subject: [PATCH 07/13] Add: test for version recovery --- tests/test_management_commands.py | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/test_management_commands.py diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py new file mode 100644 index 00000000..df98c102 --- /dev/null +++ b/tests/test_management_commands.py @@ -0,0 +1,51 @@ +from cms.test_utils.testcases import CMSTestCase +from django.core.management import call_command +from django.db import transaction + +from djangocms_versioning import constants +from djangocms_versioning.models import Version +from djangocms_versioning.test_utils.blogpost.models import BlogPost, BlogContent +from djangocms_versioning.test_utils.polls.models import Poll, PollContent + + +class CreateVersionsTestCase(CMSTestCase): + def test_create_versions(self): + # blog has no additional grouping field + structure = dict(en=5, de=2, nl=7) + + # Create BlogPosts w/o version objects + with transaction.atomic(): + post = BlogPost(name="my multi-lingual blog post") + post.save() + for language, cnt in structure.items(): + for i in range(cnt): + BlogContent(blogpost=post, language=language).save() + poll = Poll() + poll.save() + for language, cnt in structure.items(): + for i in range(cnt): + PollContent(poll=poll, language=language).save() + + self.assertEqual(Version.objects.count(), 0) + + try: + call_command('create_versions', userid=self.get_superuser().pk, state=constants.DRAFT) + except SystemExit as e: + status_code = str(e) + else: + # the "no changes" exit code is 0 + status_code = '0' + self.assertEqual(status_code, '0') + + # Blog has no additional grouping field, i.e. all except the last blog content must be archived + blog_contents = BlogContent.admin_manager.filter(blogpost=post, language=language).order_by("-pk") + self.assertEqual(blog_contents[0].versions.first().state, constants.DRAFT) + for cont in blog_contents[1:]: + self.assertEqual(cont.versions.first().state, constants.ARCHIVED) + + # Poll has additional grouping field, i.e. for each language there must be one draft (rest archived) + for language, cnt in structure.items(): + poll_contents = PollContent.admin_manager.filter(poll=poll, language=language).order_by("-pk") + self.assertEqual(poll_contents[0].versions.first().state, constants.DRAFT) + for cont in poll_contents[1:]: + self.assertEqual(cont.versions.first().state, constants.ARCHIVED) From 93c7fa30aeedcb49bdde8ab6b84d946791516695 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 20:45:38 +0100 Subject: [PATCH 08/13] Fix isort --- tests/test_management_commands.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py index df98c102..b90489da 100644 --- a/tests/test_management_commands.py +++ b/tests/test_management_commands.py @@ -1,10 +1,11 @@ -from cms.test_utils.testcases import CMSTestCase from django.core.management import call_command from django.db import transaction +from cms.test_utils.testcases import CMSTestCase + from djangocms_versioning import constants from djangocms_versioning.models import Version -from djangocms_versioning.test_utils.blogpost.models import BlogPost, BlogContent +from djangocms_versioning.test_utils.blogpost.models import BlogContent, BlogPost from djangocms_versioning.test_utils.polls.models import Poll, PollContent From ed843767bfc3c8a1c44180e58d5ff090b6f73363 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 20:51:43 +0100 Subject: [PATCH 09/13] Update docs --- docs/api/management_commands.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/api/management_commands.rst b/docs/api/management_commands.rst index a588c403..b437bec9 100644 --- a/docs/api/management_commands.rst +++ b/docs/api/management_commands.rst @@ -6,9 +6,12 @@ create_versions ``create_versions`` creates ``Version`` objects for versioned content that does not have a version assigned. This happens if djangocms-versioning is added to -content models after content already has been created. +content models after content already has been created. It can also be used as a +recovery tool if - for whatever reason - some or all ``Version`` objects have +not been created for a grouper. -By default, the existing content is assigned the draft status. +By default, the existing content is assigned the draft status. If a draft +version already exists the content will be given the archived state. Each version is assigned a user who created the version. When this command is run, either From 41a71e5c4474de9aa202b166237cccbf3a48cd66 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 21:50:55 +0100 Subject: [PATCH 10/13] fix coverage --- .../management/commands/create_versions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/djangocms_versioning/management/commands/create_versions.py b/djangocms_versioning/management/commands/create_versions.py index 5558bd45..b84fb4cc 100644 --- a/djangocms_versioning/management/commands/create_versions.py +++ b/djangocms_versioning/management/commands/create_versions.py @@ -45,14 +45,14 @@ def add_arguments(self, parser): @staticmethod def get_user(options): - if DEFAULT_USER is not None: + if DEFAULT_USER is not None: # pragma: no cover try: return User.objects.get(pk=DEFAULT_USER) except User.DoesNotExist: raise CommandError(f"No user with id {DEFAULT_USER} found " f"(specified as DJANGOCMS_VERSIONING_DEFAULT USER in settings.py") - if options["userid"] and options["username"]: + if options["userid"] and options["username"]: # pragma: no cover raise CommandError("Only either one of the options '--userid' or '--username' may be given") if options["userid"]: try: @@ -64,7 +64,7 @@ def get_user(options): return User.objects.get(**{USERNAME_FIELD: options["username"]}) except User.DoesNotExist: raise CommandError(f"No user with name {options['username']} found") - return None + return None # pragma: no cover def handle(self, *args, **options): user = self.get_user(options) @@ -78,7 +78,7 @@ def handle(self, *args, **options): f"{len(version_ids) + len(unversioned)} objects of type {Model.__name__}, thereof " f"{len(unversioned)} missing Version object" )) - if user is None and not options["dry_run"] and unversioned: + if user is None and not options["dry_run"] and unversioned: # pragma: no cover raise CommandError("Please specify a user which missing Version objects shall belong to " "either with the DJANGOCMS_VERSIONING_DEFAULT_USER setting or using " "command line arguments") @@ -100,7 +100,7 @@ def handle(self, *args, **options): state = constants.ARCHIVED break - if options["dry_run"]: + if options["dry_run"]: # pragma: no cover # Only write out change self.stdout.write((self.style.NOTICE( f"{str(orphan)} (pk={orphan.pk}) would be assigned a Version object with state {state}" @@ -115,7 +115,7 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS( f"Successfully created version object for {Model.__name__} with pk={orphan.pk}" )) - except Exception as e: + except Exception as e: # pragma: no cover self.stdout.write(self.style.ERROR( f"Failed creating version object for {Model.__name__} with pk={orphan.pk}: {e}" )) From b1d4608023d44614082c763050acda39ea411df6 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 10 Jan 2023 23:11:32 +0100 Subject: [PATCH 11/13] fix coverage --- djangocms_versioning/management/commands/create_versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/management/commands/create_versions.py b/djangocms_versioning/management/commands/create_versions.py index b84fb4cc..b079d9a1 100644 --- a/djangocms_versioning/management/commands/create_versions.py +++ b/djangocms_versioning/management/commands/create_versions.py @@ -59,7 +59,7 @@ def get_user(options): return User.objects.get(pk=options["userid"]) except User.DoesNotExist: raise CommandError(f"No user with id {options['userid']} found") - if options["username"]: + if options["username"]: # pragma: no cover try: return User.objects.get(**{USERNAME_FIELD: options["username"]}) except User.DoesNotExist: From 8afcdcf0ca4ce6498963ddfa6b08179040edee06 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Wed, 11 Jan 2023 06:23:53 +0100 Subject: [PATCH 12/13] CHANGELOG and release note indicate breaking changes. --- CHANGELOG.rst | 9 +++++--- docs/upgrade/2.0.0.rst | 50 +++++++++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 27b195ea..c6a82e52 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,7 +7,9 @@ Unreleased * add: Revert button as replacement for dysfunctional Edit button for unpublished versions * add: status indicators and drop down menus for django cms page tree -* add BREAKING: use icons provided by cms core version 4.1+ +* BREAKING: use icons provided by cms core version 4.1+ +* BREAKING: remove monkey patching of cms core version 4.1+ +* BREAKING: renamed TitleExtension to PageContentExtension as of cms core version 4.1+ * 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 @@ -15,9 +17,10 @@ Unreleased * 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`` +* 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 diff --git a/docs/upgrade/2.0.0.rst b/docs/upgrade/2.0.0.rst index e33c2a4c..00d08c25 100644 --- a/docs/upgrade/2.0.0.rst +++ b/docs/upgrade/2.0.0.rst @@ -22,6 +22,20 @@ support the latest release of each series. It supports **Python 3.8, 3.9, 3.10, and 3.11**. As for Django we highly recommend and only support the latest release of each series. +Features +======== + +Status indicators in page tree +------------------------------ + +* Status indicators are shown in the page tree as of django CMS 4.1+ +* For a more consistent user experience djangocms-versioning uses icons + provided by django CMS 4.1+ and does not provide its own icons any more. +* If ``djangocms_admin_style`` is listed in the ``INSTALLED_APPS`` setting + make sure that at least version 3.2.1 is installed. Older versions contain + a bug that interferes with djangocms-versioning's icons. + + Backwards incompatible changes in 2.0.0 ======================================= @@ -33,12 +47,32 @@ Monkey patching * As a result monkey patching has been removed from djangocms-versioning and is discouraged -Status indicators in page tree ------------------------------- +Title Extension +--------------- -* Status indicators are shown in the page tree as of django CMS 4.1+ -* For a more consistent user experience djangocms-versioning uses icons - provided by django CMS 4.1+ and does not provide its own icons any more. -* If ``djangocms_admin_style`` is listed in the ``INSTALLED_APPS`` setting - make sure that at least version 3.2.1 is installed. Older versions contain - a bug that interferes with djangocms-versioning's icons. +As of django CMS 4.1 ``TitleExtension`` in ``cms.extensions.models`` has been +renamed to ``PageContentExtension`` to keep a consistent language in the page +models. This change is reflected in djangocms-versioning 2.0.0. + +See this `PR `_. + +Icon use +-------- + +Djangocms-versioning now uses icons from the core which are only available as +of django CMS v4.1+. + + +Miscellaneous +============= + +* Adds compatibility for User models with no username field (see this + `PR `_): + Adds the possibility to configure which field of the User model uniquely + identifies the User. Default is username. + +Bug fixes +========= + +* Adjust migrations to ensure MySql compatibility (see this + `PR `_) From 46daead6bc1353b9ecf49a408f25d0db653a2e28 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 13 Jan 2023 21:21:50 +0100 Subject: [PATCH 13/13] Clarify test case with comments --- tests/test_management_commands.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py index b90489da..a0abfc7e 100644 --- a/tests/test_management_commands.py +++ b/tests/test_management_commands.py @@ -11,24 +11,28 @@ class CreateVersionsTestCase(CMSTestCase): def test_create_versions(self): - # blog has no additional grouping field - structure = dict(en=5, de=2, nl=7) + content_models_by_language = dict(en=5, de=2, nl=7) - # Create BlogPosts w/o version objects + # Arrange: + # Create BlogPosts and Poll w/o versioned content objects with transaction.atomic(): post = BlogPost(name="my multi-lingual blog post") post.save() - for language, cnt in structure.items(): + for language, cnt in content_models_by_language.items(): for i in range(cnt): + # Use save NOT objects.create to avoid creating Version object BlogContent(blogpost=post, language=language).save() poll = Poll() poll.save() - for language, cnt in structure.items(): + for language, cnt in content_models_by_language.items(): for i in range(cnt): + # Use save NOT objects.create to avoid creating Version object PollContent(poll=poll, language=language).save() - + # Verify that no Version objects have been created self.assertEqual(Version.objects.count(), 0) + # Act: + # Call create_versions command try: call_command('create_versions', userid=self.get_superuser().pk, state=constants.DRAFT) except SystemExit as e: @@ -38,6 +42,7 @@ def test_create_versions(self): status_code = '0' self.assertEqual(status_code, '0') + # Assert: # Blog has no additional grouping field, i.e. all except the last blog content must be archived blog_contents = BlogContent.admin_manager.filter(blogpost=post, language=language).order_by("-pk") self.assertEqual(blog_contents[0].versions.first().state, constants.DRAFT) @@ -45,7 +50,7 @@ def test_create_versions(self): self.assertEqual(cont.versions.first().state, constants.ARCHIVED) # Poll has additional grouping field, i.e. for each language there must be one draft (rest archived) - for language, cnt in structure.items(): + for language, cnt in content_models_by_language.items(): poll_contents = PollContent.admin_manager.filter(poll=poll, language=language).order_by("-pk") self.assertEqual(poll_contents[0].versions.first().state, constants.DRAFT) for cont in poll_contents[1:]: