From b52cfb379b269fca1044ce5dd09cf644ae3bcdce Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 3 Jul 2023 23:47:40 +0200 Subject: [PATCH 1/4] Proof of concept --- djangocms_versioning/admin.py | 33 +++++++----- djangocms_versioning/cms_toolbars.py | 47 ++++++++++++++--- djangocms_versioning/forms.py | 43 ++++++++++++++++ djangocms_versioning/managers.py | 9 +++- ...visibility_end_version_visibility_start.py | 23 +++++++++ djangocms_versioning/models.py | 35 ++++++++++++- .../admin/discard_confirmation.html | 20 ++++---- .../admin/revert_confirmation.html | 42 ++++++++-------- .../admin/timed_publication.html | 50 +++++++++++++++++++ .../admin/unpublish_confirmation.html | 20 ++++---- tests/test_admin.py | 6 +-- tests/test_models.py | 5 +- 12 files changed, 265 insertions(+), 68 deletions(-) create mode 100644 djangocms_versioning/migrations/0018_version_visibility_end_version_visibility_start.py create mode 100644 djangocms_versioning/templates/djangocms_versioning/admin/timed_publication.html diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 6bcb69cc..85780461 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -39,7 +39,7 @@ from .constants import DRAFT, INDICATOR_DESCRIPTIONS, PUBLISHED, VERSION_STATES from .emails import notify_version_author_version_unlocked from .exceptions import ConditionFailed -from .forms import grouper_form_factory +from .forms import TimedPublishingForm, grouper_form_factory from .helpers import ( content_is_unlocked_for_user, create_version_lock, @@ -955,11 +955,20 @@ def publish_view(self, request, object_id): """Publishes the specified version and redirects back to the version changelist """ - # This view always changes data so only POST requests should work + + form = TimedPublishingForm(request.POST) if request.method == "POST" else TimedPublishingForm() + if request.method == "GET" or not form.is_valid(): + return render( + request, + template_name="djangocms_versioning/admin/timed_publication.html", + context={"form": form, "errors": request.method != "GET" and not form.is_valid()}, + ) if request.method != "POST": return HttpResponseNotAllowed( - ["POST"], _("This view only supports POST method.") + ["GET", "POST"], _("This view only supports GET or POST method.") ) + visibility_start = form.cleaned_data["visibility_start"] + visibility_end = form.cleaned_data["visibility_end"] # Check version exists version = self.get_object(request, unquote(object_id)) @@ -978,9 +987,9 @@ def publish_view(self, request, object_id): return redirect(version_list_url(version.content)) # Publish the version - version.publish(request.user) + version.publish(request.user, visibility_start, visibility_end) # Display message - self.message_user(request, _("Version published")) + self.message_user(request, _("Version published"), level=messages.SUCCESS) # Redirect return redirect(version_list_url(version.content)) @@ -1185,13 +1194,13 @@ def discard_view(self, request, object_id): ) version_url = version_list_url(version.content) - if request.POST.get("discard"): - ModelClass = version.content.__class__ - deleted = version.delete() - if deleted[1]["last"]: - version_url = get_admin_url(ModelClass, "changelist") - self.message_user(request, _("The last version has been deleted")) - + ModelClass = version.content.__class__ + deleted = version.delete() + if deleted[1]["last"]: + version_url = get_admin_url(ModelClass, "changelist") + self.message_user(request, _("The last version has been deleted"), messages.SUCCESS) + else: + self.message_user(request, _("The version has been deleted."), messages.SUCCESS) return redirect(version_url) def compare_view(self, request, object_id): diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index 5ac50365..3d20e45a 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -20,11 +20,13 @@ from django.contrib.auth import get_permission_codename from django.contrib.contenttypes.models import ContentType from django.urls import reverse +from django.utils import timezone +from django.utils.formats import localize from django.utils.http import urlencode -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext, gettext_lazy as _ from djangocms_versioning.conf import LOCK_VERSIONS -from djangocms_versioning.constants import DRAFT, PUBLISHED +from djangocms_versioning.constants import DRAFT from djangocms_versioning.helpers import ( get_latest_admin_viewable_content, version_list_url, @@ -202,9 +204,31 @@ def _add_versioning_menu(self): return version_menu_label = version.short_name() + if version.visibility_start or version.visibility_end: + version_menu_label += "*" + versioning_menu = self.toolbar.get_or_create_menu( VERSIONING_MENU_IDENTIFIER, version_menu_label, disabled=False ) + if version.visibility_start: + if version.visibility_start < timezone.now(): + msg = gettext("Visible since %(datetime)s") % {"datetime": localize(version.visibility_start)} + else: + msg = gettext("Visible after %(datetime)s") % {"datetime": localize(version.visibility_start)} + versioning_menu.add_link_item( + msg, + url="", + disabled=True, + ) + if version.visibility_end: + versioning_menu.add_link_item( + gettext("Visible until %(datetime)s") % {"datetime": localize(version.visibility_end)}, + url="", + disabled=True, + ) + if version.visibility_start or version.visibility_end: + versioning_menu.add_item(Break()) + version = version.convert_to_proxy() if self.request.user.has_perm( "{app_label}.{codename}".format( @@ -227,10 +251,21 @@ def _add_versioning_menu(self): "back": self.request.get_full_path(), }) versioning_menu.add_link_item(name, url=url) + # Need separator? + if version.check_discard.as_bool(self.request.user) or version.check_publish.as_bool(self.request.user): + versioning_menu.add_item(Break()) + # Timed publishibng + if version.check_publish.as_bool(self.request.user): + versioning_menu.add_modal_item( + _("Publish with time limits"), + url=reverse("admin:{app}_{model}_publish".format( + app=proxy_model._meta.app_label, model=proxy_model.__name__.lower() + ), args=(version.pk,)), + on_close=version_list_url(version.content) + ) # Discard changes menu entry (wrt to source) if version.check_discard.as_bool(self.request.user): # pragma: no cover - versioning_menu.add_item(Break()) - versioning_menu.add_link_item( + versioning_menu.add_modal_item( _("Discard Changes"), url=reverse("admin:{app}_{model}_discard".format( app=proxy_model._meta.app_label, model=proxy_model.__name__.lower() @@ -246,9 +281,7 @@ def _get_published_page_version(self): if not isinstance(self.toolbar.obj, PageContent) or not self.page: return - return PageContent._original_manager.filter( - page=self.page, language=language, versions__state=PUBLISHED - ).first() + return PageContent.objects.filter(page=self.page, language=language).first() def _add_view_published_button(self): """Helper method to add a publish button to the toolbar diff --git a/djangocms_versioning/forms.py b/djangocms_versioning/forms.py index 7e742335..47067264 100644 --- a/djangocms_versioning/forms.py +++ b/djangocms_versioning/forms.py @@ -1,6 +1,10 @@ from functools import lru_cache from django import forms +from django.contrib.admin.widgets import AdminSplitDateTime +from django.core.exceptions import ValidationError +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from . import versionables @@ -50,3 +54,42 @@ def grouper_form_factory(content_model, language=None): ), }, ) + + +class TimedPublishingForm(forms.Form): + visibility_start = forms.SplitDateTimeField( + required=False, + label=_("Visible after"), + help_text=_("Leave empty for immediate public visibility"), + widget=AdminSplitDateTime, + ) + + visibility_end = forms.SplitDateTimeField( + required=False, + label=_("Visible until"), + help_text=_("Leave empty for unrestricted public visibility"), + widget=AdminSplitDateTime, + ) + + def clean_visibility_start(self): + visibility_start = self.cleaned_data["visibility_start"] + if visibility_start and visibility_start < timezone.now(): + raise ValidationError( + _("The date and time must be in the future."), code="future" + ) + return visibility_start + + def clean_visibility_end(self): + visibility_end = self.cleaned_data["visibility_end"] + if visibility_end and visibility_end < timezone.now(): + raise ValidationError( + _("The date and time must be in the future."), code="future" + ) + return visibility_end + + def clean(self): + if self.cleaned_data.get("visibility_start") and self.cleaned_data.get("visibility_end"): + if self.cleaned_data["visibility_start"] >= self.cleaned_data["visibility_end"]: + raise ValidationError( + _("The time until the content is visible must be after the time " + "the content becomes visible."), code="time_interval") diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index 55d615e8..4bc1bfef 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -3,6 +3,8 @@ from django.contrib.auth import get_user_model from django.db import models +from django.db.models import Q +from django.utils import timezone from . import constants from .constants import PUBLISHED @@ -19,7 +21,12 @@ def get_queryset(self): queryset = super().get_queryset() if not self.versioning_enabled: return queryset - return queryset.filter(versions__state=PUBLISHED) + now = timezone.now() + return queryset.filter( + Q(versions__visibility_start=None) | Q(versions__visibility_start__lt=now), + Q(versions__visibility_end=None) | Q(versions__visibility_end__gt=now), + versions__state=PUBLISHED, + ) def create(self, *args, **kwargs): obj = super().create(*args, **kwargs) diff --git a/djangocms_versioning/migrations/0018_version_visibility_end_version_visibility_start.py b/djangocms_versioning/migrations/0018_version_visibility_end_version_visibility_start.py new file mode 100644 index 00000000..3aa6152b --- /dev/null +++ b/djangocms_versioning/migrations/0018_version_visibility_end_version_visibility_start.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.7 on 2023-07-03 11:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_versioning', '0017_merge_20230514_1027'), + ] + + operations = [ + migrations.AddField( + model_name='version', + name='visibility_end', + field=models.DateTimeField(blank=True, default=None, help_text='Leave empty for unrestricted public visibility', null=True, verbose_name='visible until'), + ), + migrations.AddField( + model_name='version', + name='visibility_start', + field=models.DateTimeField(blank=True, default=None, help_text='Leave empty for immediate public visibility', null=True, verbose_name='visible after'), + ), + ] diff --git a/djangocms_versioning/models.py b/djangocms_versioning/models.py index b36cc5bd..8f834a89 100644 --- a/djangocms_versioning/models.py +++ b/djangocms_versioning/models.py @@ -110,6 +110,21 @@ class Version(models.Model): verbose_name=_("locked by"), related_name="locking_users", ) + visibility_start = models.DateTimeField( + default=None, + blank=True, + null=True, + verbose_name=_("visible after"), + help_text=_("Leave empty for immediate public visibility"), + ) + + visibility_end = models.DateTimeField( + default=None, + blank=True, + null=True, + verbose_name=_("visible until"), + help_text=_("Leave empty for unrestricted public visibility"), + ) source = models.ForeignKey( "self", @@ -137,8 +152,14 @@ def verbose_name(self): ) def short_name(self): + state = dict(constants.VERSION_STATES)[self.state] + if self.state == constants.PUBLISHED: + if self.visibility_start and self.visibility_start > timezone.now(): + state = _("Pending") + elif self.visibility_end and self.visibility_end < timezone.now(): + state = _("Expired") return _("Version #{number} ({state})").format( - number=self.number, state=dict(constants.VERSION_STATES)[self.state] + number=self.number, state=state ) def locked_message(self): @@ -330,7 +351,7 @@ def _set_archive(self, user): def can_be_published(self): return can_proceed(self._set_publish) - def publish(self, user): + def publish(self, user, visibility_start=None, visibility_end=None): """Change state to PUBLISHED and unpublish currently published versions""" # trigger pre operation signal @@ -338,6 +359,8 @@ def publish(self, user): constants.OPERATION_PUBLISH, version=self ) self._set_publish(user) + self.visibility_start = visibility_start + self.visibility_end = visibility_end self.modified = timezone.now() self.save() StateTracking.objects.create( @@ -383,6 +406,14 @@ def _set_publish(self, user): possible to be left with inconsistent data)""" pass + def is_visible(self): + now = timezone.now() + return self.state == constants.PUBLISHED and ( + self.visibility_start is None or self.visibility_start < now + ) and ( + self.visibility_end is None or self.visibility_end > now + ) + check_unpublish = Conditions([ in_state([constants.PUBLISHED], _("Version is not in published state")), draft_is_not_locked(lock_draft_error_message), diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/discard_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/discard_confirmation.html index d01e5e10..5c7188da 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/discard_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/discard_confirmation.html @@ -1,6 +1,5 @@ {% extends "admin/base_site.html" %} {% load i18n admin_urls static %} -{% block title %}{% trans "Discard Confirmation" %}{% endblock %} {% block extrahead %} {{ block.super }} @@ -12,19 +11,20 @@ {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %} {% block content %} +

{% block title %}{% trans "Discard Confirmation" %}{% endblock %}

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

{{ object_name }}

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

{% csrf_token %} - - - - +
{% endblock %} diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html index d7a18d89..3de7d687 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html @@ -1,6 +1,5 @@ {% extends "admin/base_site.html" %} {% load i18n admin_urls static %} -{% block title %}{% translate "Revert Confirmation" %}{% endblock %} {% block extrahead %} {{ block.super }} @@ -13,6 +12,7 @@ {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %} {% block content %} +

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

{% if draft_version %} {% translate "Reverting to this version may cause loss of an existing draft version. Please select an option to continue" %} @@ -24,25 +24,25 @@

{{ object_name }}

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

{% csrf_token %} - {% if draft_version %} - - - {% else %} - - {% endif %} - - - +
+ {% if draft_version %} + + + {% else %} + + {% endif %} + + {% translate 'No, take me back' %} + +
{% endblock %} diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/timed_publication.html b/djangocms_versioning/templates/djangocms_versioning/admin/timed_publication.html new file mode 100644 index 00000000..cdd6bce6 --- /dev/null +++ b/djangocms_versioning/templates/djangocms_versioning/admin/timed_publication.html @@ -0,0 +1,50 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} +{% block extrahead %}{{ block.super }} + + +{{ form.media }} +{% endblock %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block breadcrumbs %}{% endblock %} + +{% block content %} +

{% block title %}{% translate "Publish with time limits" %}{% endblock %}

+ +
+ {% blocktrans %} +

You can publish with optional dates and times in the future when the content will become visible + and/or ceases to be visible to visitors of the site.

+

Once published the contents or times cannot be changed anymore.

+ {% endblocktrans %} + {% if errors %} +

+ {% blocktranslate count counter=errors|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %} +

+ {% endif %} + {% csrf_token %} +
+ + {{ form.non_field_errors }} +
+
+ {{ form.visibility_start.errors }} + + {{ form.visibility_start }} +
+
+ {{ form.visibility_end.errors }} + + {{ form.visibility_end }} +
+
+
+
+ +
+
+{% endblock %} diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html index 275a5231..7a87ab53 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html @@ -1,6 +1,5 @@ {% extends "admin/base_site.html" %} {% load i18n admin_urls static %} -{% block title %}{% translate "Revert Confirmation" %}{% endblock %} {% block extrahead %} {{ block.super }} @@ -12,6 +11,7 @@ {% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %} {% block content %} +

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

{% translate "Unpublishing will remove this version from live. Are you sure you want to unpublish?" %}

{{ object_name }}

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

@@ -22,13 +22,15 @@

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

{% csrf_token %} - - - - +
+ + + + +
{% endblock %} diff --git a/tests/test_admin.py b/tests/test_admin.py index d02f5402..dbb09784 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1447,15 +1447,15 @@ def test_publish_view_cant_be_accessed_by_get_request(self): ) with self.login_user_context(self.get_staff_user_with_no_permissions()): - response = self.client.get(url) + response = self.client.put(url) self.assertEqual(response.status_code, 405) # Django 2.2 backwards compatibility if hasattr(response, "_headers"): - self.assertEqual(response._headers.get("allow"), ("Allow", "POST")) + self.assertEqual(response._headers.get("allow"), ("Allow", "GET, POST")) else: - self.assertEqual(response.headers.get("Allow"), "POST") + self.assertEqual(response.headers.get("Allow"), "GET, POST") # status hasn't changed poll_version_ = Version.objects.get(pk=poll_version.pk) diff --git a/tests/test_models.py b/tests/test_models.py index 5555f075..98ac5b3e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,7 +7,6 @@ from djangocms_versioning.constants import DRAFT, PUBLISHED from djangocms_versioning.datastructures import VersionableItem, default_copy -from djangocms_versioning.helpers import remove_published_where from djangocms_versioning.models import Version, VersionQuerySet from djangocms_versioning.test_utils import factories from djangocms_versioning.test_utils.polls.cms_config import PollsCMSConfig @@ -244,11 +243,11 @@ def test_get_for_content(self): version = factories.PollVersionFactory() self.assertEqual(Version.objects.get_for_content(version.content), version) - def test_versioned_queryset_return_full_with_helper_method(self): + def test_versioned_admin_manager(self): """With an extra helper method we can return the full queryset""" factories.PollVersionFactory() normal_count = PollContent.objects.all() - full_count = remove_published_where(PollContent.objects.all()) + full_count = PollContent.admin_manager.all() self.assertEqual(normal_count.count(), 0) self.assertEqual(full_count.count(), 1) From d16d3643bd6b91e573881b004536b448e5ced0be Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 4 Jul 2023 00:03:52 +0200 Subject: [PATCH 2/4] Remove remove_published_where --- djangocms_versioning/helpers.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index d011947e..8aa56a75 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -294,23 +294,9 @@ def remove_published_where(queryset): """ By default, the versioned queryset filters out so that only versions that are published are returned. If you need to return the full queryset - this method can be used. - - It will modify the sql to remove `where state = 'published'` + use the "admin_manager" instead of "objects" """ - where_children = queryset.query.where.children - all_except_published = [ - lookup for lookup in where_children - if not ( - lookup.lookup_name == "exact" and - lookup.rhs == PUBLISHED and - lookup.lhs.field.name == "state" - ) - ] - - queryset.query.where = WhereNode() - queryset.query.where.children = all_except_published - return queryset + raise NotImplementedError("remove_published_where has beenreplaced by ContentObj.admin_manager") def get_latest_admin_viewable_content( From 382354363b6f9e8eb13c17a704ff408f102b9a6b Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 4 Jul 2023 00:48:09 +0200 Subject: [PATCH 3/4] Add some comments --- djangocms_versioning/cms_toolbars.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index 3d20e45a..16fd7207 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -205,11 +205,13 @@ def _add_versioning_menu(self): version_menu_label = version.short_name() if version.visibility_start or version.visibility_end: + # Mark time-restricted visibility in the toolbar version_menu_label += "*" versioning_menu = self.toolbar.get_or_create_menu( VERSIONING_MENU_IDENTIFIER, version_menu_label, disabled=False ) + # Inform about time restrictions if version.visibility_start: if version.visibility_start < timezone.now(): msg = gettext("Visible since %(datetime)s") % {"datetime": localize(version.visibility_start)} @@ -227,6 +229,7 @@ def _add_versioning_menu(self): disabled=True, ) if version.visibility_start or version.visibility_end: + # Add a break if info fields on time restrictions have been added versioning_menu.add_item(Break()) version = version.convert_to_proxy() From 99282fe4dad113e841c47bb6a271a8c6eb9447c4 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 5 Mar 2024 12:48:05 +0100 Subject: [PATCH 4/4] Fix linting issue --- djangocms_versioning/cms_toolbars.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index 049e5449..94337cff 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -253,9 +253,10 @@ def _add_versioning_menu(self): if version.check_publish.as_bool(self.request.user): versioning_menu.add_modal_item( _("Publish with time limits"), - url=reverse("admin:{app}_{model}_publish".format( - app=proxy_model._meta.app_label, model=proxy_model.__name__.lower() - ), args=(version.pk,)), + url=reverse( + f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_publish", + args=(version.pk,) + ), on_close=version_list_url(version.content) ) # Discard changes menu entry (wrt to source)