Skip to content

feat: Simple publishing beginning and end dates #341

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

Closed
wants to merge 11 commits into from
31 changes: 20 additions & 11 deletions djangocms_versioning/admin.py
Original file line number Diff line number Diff line change
@@ -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):
45 changes: 42 additions & 3 deletions djangocms_versioning/cms_toolbars.py
Original file line number Diff line number Diff line change
@@ -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",
44 changes: 43 additions & 1 deletion djangocms_versioning/forms.py
Original file line number Diff line number Diff line change
@@ -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")
9 changes: 8 additions & 1 deletion djangocms_versioning/managers.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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'),
),
]
35 changes: 33 additions & 2 deletions djangocms_versioning/models.py
Original file line number Diff line number Diff line change
@@ -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,14 +364,16 @@ 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
action_token = send_pre_version_operation(
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")),
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}{{ block.super }}
<script src="{% url 'admin:jsi18n' %}"></script>
<script src="{% static "admin/js/core.js" %}"></script>
{{ form.media }}
{% endblock %}

{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/forms.css" %}">{% endblock %}

{% block breadcrumbs %}{% endblock %}

{% block content %}
<h1>{% block title %}{% translate "Publish with time limits" %}{% endblock %}</h1>

<form action="" method="POST">
{% blocktrans %}
<p>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.</p>
<p>Once published the contents or times cannot be changed anymore.</p>
{% endblocktrans %}
{% if errors %}
<p class="errornote">
{% blocktranslate count counter=errors|length %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktranslate %}
</p>
{% endif %}
{% csrf_token %}
<div>

{{ form.non_field_errors }}
<div class="form-row">
<div class="fieldBox">
{{ form.visibility_start.errors }}
<label for="{{ form.visibility_start.id_for_label }}"><b>{{ form.visibility_start.label }}</b></label>
{{ form.visibility_start }}
</div>
<div class="fieldBox">
{{ form.visibility_end.errors }}
<label for="{{ form.visibility_end.id_for_label }}"><b>{{ form.visibility_end.label }}</b></label>
{{ form.visibility_end }}
</div>
</div>
</div>
<div class="submit-row">
<input class="button default"
type="submit"
value="{% translate "Publish" %}">
</div>
</form>
{% endblock %}
6 changes: 3 additions & 3 deletions tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -1546,15 +1546,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)