Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add management command to create version objects #304

Merged
merged 15 commits into from
Jan 13, 2023
8 changes: 6 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@ 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
* 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
* 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``
* 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

Expand Down
4 changes: 4 additions & 0 deletions djangocms_versioning/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@
USERNAME_FIELD = getattr(
settings, "DJANGOCMS_VERSIONING_USERNAME_FIELD", 'username'
)

DEFAULT_USER = getattr(
settings, "DJANGOCMS_VERSIONING_DEFAULT_USER", None
)
Empty file.
121 changes: 121 additions & 0 deletions djangocms_versioning/management/commands/create_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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


User = get_user_model()


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. ' \
'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 if key != constants.UNPUBLISHED],
help=f"state of newly created version object (defaults to {constants.DRAFT})"
)
parser.add_argument(
"--username",
type=str,
help="Username of user to create the missing Version objects"
)
parser.add_argument(
"--userid",
type=int,
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",
)

@staticmethod
def get_user(options):
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"]: # pragma: no cover
raise CommandError("Only either one of the options '--userid' or '--username' may be given")
if options["userid"]:
try:
return User.objects.get(pk=options["userid"])
except User.DoesNotExist:
raise CommandError(f"No user with id {options['userid']} found")
if options["username"]: # pragma: no cover
try:
return User.objects.get(**{USERNAME_FIELD: options["username"]})
except User.DoesNotExist:
raise CommandError(f"No user with name {options['username']} found")
return None # pragma: no cover

def handle(self, *args, **options):
user = self.get_user(options)

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 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")

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"]: # 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}"
)))
else:
try:
Version.objects.create(
content=orphan,
state=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: # pragma: no cover
self.stdout.write(self.style.ERROR(
f"Failed creating version object for {Model.__name__} with pk={orphan.pk}: {e}"
))
File renamed without changes.
46 changes: 46 additions & 0 deletions docs/api/management_commands.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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. 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. 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

* 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,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. 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,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
File renamed without changes.
13 changes: 10 additions & 3 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@ 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
:caption: Internals:

admin_architecture

.. toctree::
:maxdepth: 2
:caption: Release notes:

upgrade/2.0.0


Glossary
--------
Expand Down
78 changes: 78 additions & 0 deletions docs/upgrade/2.0.0.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
.. _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.

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
=======================================

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

Title Extension
---------------

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 <https://github.com/django-cms/djangocms-versioning/pull/291>`_.

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 <https://github.com/django-cms/djangocms-versioning/pull/293>`_):
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 <https://github.com/django-cms/djangocms-versioning/pull/287>`_)
57 changes: 57 additions & 0 deletions tests/test_management_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 BlogContent, BlogPost
from djangocms_versioning.test_utils.polls.models import Poll, PollContent


class CreateVersionsTestCase(CMSTestCase):
def test_create_versions(self):
content_models_by_language = dict(en=5, de=2, nl=7)

# 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 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 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:
status_code = str(e)
else:
# the "no changes" exit code is 0
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)
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 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:]:
self.assertEqual(cont.versions.first().state, constants.ARCHIVED)