diff --git a/README.rst b/README.rst index 782a6ddb..e59948c1 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|PyPiVersion| |DjVersion| |CmsVersion| +|PyPiVersion| |PyVersion| |DjVersion| |CmsVersion| |Coverage| ********************* django CMS Versioning @@ -117,3 +117,6 @@ do not forget to run the ``compilemessages`` management command. .. |CmsVersion| image:: https://img.shields.io/pypi/frameworkversions/django-cms/djangocms-versioning.svg?style=flat-square :target: https://pypi.python.org/pypi/djangocms-versioning :alt: django CMS versions + +.. |Coverage| image:: https://codecov.io/gh/django-cms/djangocms-versioning/graph/badge.svg?token=Jyx7Ilpibf + :target: https://codecov.io/gh/django-cms/djangocms-versioning \ No newline at end of file diff --git a/djangocms_versioning/constants.py b/djangocms_versioning/constants.py index aaa9efd4..e6c28cb2 100644 --- a/djangocms_versioning/constants.py +++ b/djangocms_versioning/constants.py @@ -26,3 +26,7 @@ "archived": _("Archived"), "empty": _("Empty"), } + +DELETE_NON_PUBLIC_ONLY = "non-public only" +DELETE_ANY = "any" +DELETE_NONE = "none" diff --git a/djangocms_versioning/models.py b/djangocms_versioning/models.py index 96e9adf8..f15f51bc 100644 --- a/djangocms_versioning/models.py +++ b/djangocms_versioning/models.py @@ -35,8 +35,23 @@ permission_error_message = _("You do not have permission to perform this action") +def PROTECT_IF_PUBLIC_VERSION(collector, field, sub_objs, using): + public_exists = sub_objs.filter(state=constants.PUBLISHED) + if public_exists: + # Any public objects? + raise models.ProtectedError( + f"Cannot delete some instances of model '{field.remote_field.model.__name__}' because they are " + f"referenced through a protected foreign key: '{sub_objs[0].__class__.__name__}.{field.name}'", + public_exists, + ) + models.SET_NULL(collector, field, sub_objs, using) + + def allow_deleting_versions(collector, field, sub_objs, using): - if ALLOW_DELETING_VERSIONS: + if ALLOW_DELETING_VERSIONS == constants.DELETE_NON_PUBLIC_ONLY: + PROTECT_IF_PUBLIC_VERSION(collector, field, sub_objs, using) + elif ALLOW_DELETING_VERSIONS == constants.DELETE_ANY or ALLOW_DELETING_VERSIONS is True: + # Backwards compatibility: True means DELETE_ANY models.SET_NULL(collector, field, sub_objs, using) else: models.PROTECT(collector, field, sub_objs, using) @@ -132,7 +147,14 @@ class Meta: ) def __str__(self): - return f"Version #{self.pk}" + state = dict(constants.VERSION_STATES).get(self.state, self.state) + if self.object_id: + try: + return f"Version #{self.pk} ({state}) of {self.content}" + except Exception: + # In case the content object cannot be stringified + pass + return f"Version #{self.pk} ({state})" def verbose_name(self): return _("Version #{number} ({state} {date})").format( diff --git a/docs/api/settings.rst b/docs/api/settings.rst index 27d86648..d8fa3058 100644 --- a/docs/api/settings.rst +++ b/docs/api/settings.rst @@ -4,7 +4,7 @@ Settings for djangocms Versioning .. py:attribute:: DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS - Defaults to ``False`` + Defaults to ``False`` (``constants.DELETE_NONE``) This setting controls if the ``source`` field of a ``Version`` object is protected. It is protected by default which implies that Django will not allow a user @@ -14,10 +14,17 @@ Settings for djangocms Versioning This is to protect the record of how different versions have come about. - If set to ``True`` users can delete version objects if the have the appropriate - rights. Set this to ``True`` if you want users to be able to delete versioned - objects and you do not need a full history of versions, e.g. for documentation - purposes. + If set to ``constants.DELETE_ANY`` (``"any"``)) users can delete version objects + if the have the appropriate rights. Set this to ``"any"`` if you want users to be + able to delete versioned objects and you do not need a full history of versions, + e.g. for documentation purposes. + + If set to ``constants.DELETE_NON_PUBLIC_ONLY`` (``"non-public only"``) users can + delete version objects which are not published and which are not a source for + another version object. This allows users to delete draft, unpublished, or archived + versions, but protects published versions from deletion. This is particularly + useful if you want to allow users to clean up their version history, but still want to + ensure that published content remains documented and is not deleted accidentally. The latest version (which is not a source of a newer version) can always be deleted (if the user has the appropriate rights). diff --git a/tests/test_models.py b/tests/test_models.py index ffeb37a9..b2eb2a20 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -238,6 +238,24 @@ def test_grouper(self): version = factories.PollVersionFactory() self.assertEqual(version.grouper, version.content.poll) + def test_version_str(self): + version = factories.PollVersionFactory() + expected_str = f"Version #{version.pk} (Draft) of {version.content}" + self.assertEqual(str(version), expected_str) + + def test_version_str_failing(self): + def failing_str_method(self): + raise Exception("Cannot stringify") + + version = factories.PollVersionFactory() + original_str_method = version.content.__class__.__str__ + version.content.__class__.__str__ = failing_str_method + + expected_str = f"Version #{version.pk} (Draft)" + self.assertEqual(str(version), expected_str) + + version.content.__class__.__str__ = original_str_method + class TestVersionQuerySet(CMSTestCase): def test_version_uses_versionqueryset_as_manager(self): diff --git a/tests/test_settings.py b/tests/test_settings.py index c73afb7b..df47d7b1 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,7 +8,7 @@ class DeletionTestCase(CMSTestCase): - @override_settings(DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS=False) + @override_settings(DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS=constants.DELETE_NONE) def test_deletion_not_possible(self): # Since djangocms_versionings.models stores the setting, we need to update that copy versioning_models.ALLOW_DELETING_VERSIONS = settings.DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS @@ -32,7 +32,7 @@ def test_deletion_not_possible(self): self.assertRaises(models.deletion.ProtectedError, versioning_models.Version.objects.get(pk=pk1).content.delete) - @override_settings(DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS=True) + @override_settings(DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS=constants.DELETE_ANY) def test_deletion_possible(self): # Since djangocms_versionings.models stores the setting, we need to update that copy versioning_models.ALLOW_DELETING_VERSIONS = settings.DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS @@ -55,3 +55,52 @@ def test_deletion_possible(self): # try deleting and see if error is raised versioning_models.Version.objects.get(pk=pk1).content.delete() self.assertIsNone(versioning_models.Version.objects.get(pk=version2.pk).source) + + @override_settings(DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS=constants.DELETE_NON_PUBLIC_ONLY) + def test_deletion_non_public_possible(self): + # Since djangocms_versionings.models stores the setting, we need to update that copy + versioning_models.ALLOW_DELETING_VERSIONS = settings.DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS + poll = factories.PollFactory() + version1 = factories.PollVersionFactory( + content__poll=poll, + content__language="en", + ) + pk1 = version1.pk + # Now publish and then edit redirect to create a draft on top of published version + version1.publish(user=self.get_superuser()) + self.assertEqual(versioning_models.Version.objects.get(pk=pk1).state, constants.PUBLISHED) + + version2 = version1.copy(created_by=self.get_superuser()) + version2.save() + + # Check of source field is set + self.assertIsNotNone(version2.source) + + # try deleting and see if error is raised + versioning_models.Version.objects.get(pk=pk1).content.delete() + self.assertIsNone(versioning_models.Version.objects.get(pk=version2.pk).source) + + @override_settings(DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS=constants.DELETE_NON_PUBLIC_ONLY) + def test_deletion_public_not_possible(self): + # Since djangocms_versionings.models stores the setting, we need to update that copy + versioning_models.ALLOW_DELETING_VERSIONS = settings.DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS + poll = factories.PollFactory() + version1 = factories.PollVersionFactory( + content__poll=poll, + content__language="en", + ) + pk1 = version1.pk + # Now publish and then edit redirect to create a draft on top of published version + version1.publish(user=self.get_superuser()) + self.assertEqual(versioning_models.Version.objects.get(pk=pk1).state, constants.PUBLISHED) + + version2 = version1.copy(created_by=self.get_superuser()) + version2.save() + version2.publish(user=self.get_superuser()) + + # Check of source field is set + self.assertIsNotNone(version2.source) + + # try deleting and see if error is raised + self.assertRaises(models.deletion.ProtectedError, + versioning_models.Version.objects.get(pk=pk1).content.delete)