diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index afe89e4d..e81d03a2 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -41,7 +41,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, @@ -1024,11 +1024,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)) @@ -1053,7 +1062,7 @@ def publish_view(self, request, object_id): return self._internal_redirect(requested_redirect, redirect_url) # Publish the version - version.publish(request.user) + version.publish(request.user, visibility_start, visibility_end) # Display message self.message_user(request, _("Version published")) @@ -1276,13 +1285,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 a2064964..a2eb3496 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -24,8 +24,10 @@ 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 packaging import version from djangocms_versioning.conf import ALLOW_DELETING_VERSIONS, LOCK_VERSIONS @@ -196,9 +198,34 @@ def _add_versioning_menu(self): return 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)} + 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: + # Add a break if info fields on time restrictions have been added + versioning_menu.add_item(Break()) + version = version.convert_to_proxy() if self.request.user.has_perm( "{app_label}.{codename}".format( @@ -222,10 +249,22 @@ def _add_versioning_menu(self): "back": self.toolbar.request_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( + 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) 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( f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_discard", diff --git a/djangocms_versioning/forms.py b/djangocms_versioning/forms.py index 4dc8401a..a4ecedcf 100644 --- a/djangocms_versioning/forms.py +++ b/djangocms_versioning/forms.py @@ -3,7 +3,10 @@ from functools import lru_cache from django import forms -from django.contrib.admin.widgets import AutocompleteSelect +from django.contrib.admin.widgets import AdminSplitDateTime, AutocompleteSelect +from django.core.exceptions import ValidationError +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from . import versionables @@ -66,3 +69,42 @@ def grouper_form_factory(content_model, language=None, admin_site=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 5a289487..30a3a5c5 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 0f1dec26..7592f2a2 100644 --- a/djangocms_versioning/models.py +++ b/djangocms_versioning/models.py @@ -115,6 +115,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", @@ -142,8 +157,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): @@ -343,7 +364,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 @@ -351,6 +372,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( @@ -399,6 +422,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([ user_can_publish(permission_error_message), in_state([constants.PUBLISHED], _("Version is not in published state")), 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 %} +