mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
Added Entry deleted flagging and ability for news authors and moderators to view marked items (#1882)
This commit is contained in:
@@ -25,7 +25,7 @@ def moderators():
|
|||||||
|
|
||||||
def can_view(user, entry):
|
def can_view(user, entry):
|
||||||
return (
|
return (
|
||||||
entry.is_published
|
(entry.is_published and entry.deleted_at is None)
|
||||||
or user == entry.author
|
or user == entry.author
|
||||||
or (user is not None and user.has_perm("news.view_entry"))
|
or (user is not None and user.has_perm("news.view_entry"))
|
||||||
)
|
)
|
||||||
|
|||||||
32
news/migrations/0012_entry_deleted_at_entry_deleted_by.py
Normal file
32
news/migrations/0012_entry_deleted_at_entry_deleted_by.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-05 19:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("news", "0011_entry_summary"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="entry",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="entry",
|
||||||
|
name="deleted_by",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="deleted_entries",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -93,6 +93,14 @@ class Entry(models.Model):
|
|||||||
summary = models.TextField(
|
summary = models.TextField(
|
||||||
blank=True, default="", help_text="AI generated summary. Delete to regenerate."
|
blank=True, default="", help_text="AI generated summary. Delete to regenerate."
|
||||||
)
|
)
|
||||||
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
deleted_by = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="deleted_entries",
|
||||||
|
)
|
||||||
|
|
||||||
objects = EntryManager()
|
objects = EntryManager()
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ def make_entry(db):
|
|||||||
kwargs.setdefault("approved_at", approved_at)
|
kwargs.setdefault("approved_at", approved_at)
|
||||||
kwargs.setdefault("moderator", moderator)
|
kwargs.setdefault("moderator", moderator)
|
||||||
kwargs.setdefault("publish_at", publish_at)
|
kwargs.setdefault("publish_at", publish_at)
|
||||||
|
kwargs.setdefault("deleted_at", None)
|
||||||
kwargs.setdefault("title", "Admin User's Q3 Update")
|
kwargs.setdefault("title", "Admin User's Q3 Update")
|
||||||
entry = baker.make(model_class, **kwargs)
|
entry = baker.make(model_class, **kwargs)
|
||||||
entry.author.set_password("password")
|
entry.author.set_password("password")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from datetime import datetime, timezone
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
from ..acl import (
|
from ..acl import (
|
||||||
@@ -176,3 +177,68 @@ def test_entry_author_needs_moderation_allowlist(make_entry, make_user, settings
|
|||||||
|
|
||||||
settings.NEWS_MODERATION_ALLOWLIST = [user.pk]
|
settings.NEWS_MODERATION_ALLOWLIST = [user.pk]
|
||||||
assert author_needs_moderation(entry) is False
|
assert author_needs_moderation(entry) is False
|
||||||
|
|
||||||
|
|
||||||
|
# Tests for soft delete functionality (deleted_at field)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_class", NEWS_MODELS)
|
||||||
|
def test_can_view_published_entry_not_deleted(make_entry, regular_user, model_class):
|
||||||
|
"""Test that published, non-deleted entries are viewable by public."""
|
||||||
|
entry = make_entry(model_class, approved=True, deleted_at=None)
|
||||||
|
|
||||||
|
assert can_view(None, entry) is True
|
||||||
|
assert can_view(regular_user, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_class", NEWS_MODELS)
|
||||||
|
def test_can_view_published_entry_deleted_public(make_entry, regular_user, model_class):
|
||||||
|
"""Test that published but deleted entries are NOT viewable by public."""
|
||||||
|
deleted_time = datetime.now(timezone.utc)
|
||||||
|
entry = make_entry(model_class, approved=True, deleted_at=deleted_time)
|
||||||
|
|
||||||
|
assert can_view(None, entry) is False
|
||||||
|
assert can_view(regular_user, entry) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_class", NEWS_MODELS)
|
||||||
|
def test_can_view_deleted_entry_by_author(make_entry, model_class):
|
||||||
|
"""Test that authors can still view their deleted entries."""
|
||||||
|
deleted_time = datetime.now(timezone.utc)
|
||||||
|
entry = make_entry(model_class, approved=True, deleted_at=deleted_time)
|
||||||
|
|
||||||
|
assert can_view(entry.author, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_class", NEWS_MODELS)
|
||||||
|
def test_can_view_deleted_entry_by_moderator(make_entry, make_user, model_class):
|
||||||
|
"""Test that users with view permission can view deleted entries."""
|
||||||
|
deleted_time = datetime.now(timezone.utc)
|
||||||
|
entry = make_entry(model_class, approved=True, deleted_at=deleted_time)
|
||||||
|
|
||||||
|
user_with_view_perm = make_user(perms=["news.view_entry"])
|
||||||
|
|
||||||
|
assert can_view(user_with_view_perm, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_class", NEWS_MODELS)
|
||||||
|
def test_can_view_deleted_entry_by_superuser(make_entry, superuser, model_class):
|
||||||
|
"""Test that superusers can view deleted entries."""
|
||||||
|
deleted_time = datetime.now(timezone.utc)
|
||||||
|
entry = make_entry(model_class, approved=True, deleted_at=deleted_time)
|
||||||
|
|
||||||
|
# Superuser can view deleted entries
|
||||||
|
assert can_view(superuser, entry) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_class", NEWS_MODELS)
|
||||||
|
@pytest.mark.parametrize("deleted_at_value", [None, datetime.now(timezone.utc)])
|
||||||
|
def test_can_view_unpublished_entry_with_deletion(
|
||||||
|
make_entry, regular_user, model_class, deleted_at_value
|
||||||
|
):
|
||||||
|
"""Test that unpublished entries follow same rules regardless of deletion status."""
|
||||||
|
entry = make_entry(model_class, approved=False, deleted_at=deleted_at_value)
|
||||||
|
|
||||||
|
assert can_view(None, entry) is False
|
||||||
|
assert can_view(regular_user, entry) is False
|
||||||
|
assert can_view(entry.author, entry) is True
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ class EntryListView(ListView):
|
|||||||
context_object_name = "entry_list" # Ensure children use the same name
|
context_object_name = "entry_list" # Ensure children use the same name
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
result = super().get_queryset().select_related("author").filter(published=True)
|
result = (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.select_related("author")
|
||||||
|
.filter(published=True, deleted_at__isnull=True)
|
||||||
|
)
|
||||||
right_now = now()
|
right_now = now()
|
||||||
for entry in result:
|
for entry in result:
|
||||||
entry.display_publish_at = display_publish_at(entry.publish_at, right_now)
|
entry.display_publish_at = display_publish_at(entry.publish_at, right_now)
|
||||||
@@ -125,7 +130,12 @@ class EntryModerationListView(LoginRequiredMixin, UserPassesTestMixin, ListView)
|
|||||||
paginate_by = None
|
paginate_by = None
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return super().get_queryset().select_related("author").filter(approved=False)
|
return (
|
||||||
|
super()
|
||||||
|
.get_queryset()
|
||||||
|
.select_related("author")
|
||||||
|
.filter(approved=False, deleted_at__isnull=True)
|
||||||
|
)
|
||||||
|
|
||||||
def test_func(self):
|
def test_func(self):
|
||||||
return can_approve(self.request.user)
|
return can_approve(self.request.user)
|
||||||
@@ -373,6 +383,12 @@ class EntryDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
|
|||||||
template_name = "news/confirm_delete.html"
|
template_name = "news/confirm_delete.html"
|
||||||
success_url = reverse_lazy("news")
|
success_url = reverse_lazy("news")
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
self.object.deleted_at = now()
|
||||||
|
self.object.deleted_by = self.request.user
|
||||||
|
self.object.save(update_fields=["deleted_at", "deleted_by"])
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
def test_func(self):
|
def test_func(self):
|
||||||
entry = self.get_object()
|
entry = self.get_object()
|
||||||
return entry.can_delete(self.request.user)
|
return entry.can_delete(self.request.user)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="py-0 px-3 mb-3 text-center md:py-6 md:px-0">
|
<div class="py-0 px-3 mb-3 text-center md:py-6 md:px-0">
|
||||||
<h1 class="text-3xl">{% translate 'Please confirm your choice below' %}</h1>
|
<h1 class="text-3xl">{% translate 'Please confirm your choice below' %}</h1>
|
||||||
<p>
|
<p>
|
||||||
{% blocktrans with entry_title=entry.title %}Are you sure you want to permanently delete <strong>{{ entry_title }}</strong>?{% endblocktrans %}
|
{% blocktrans with entry_title=entry.title %}Are you sure you want to mark <strong>{{ entry_title }}</strong> as deleted?{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST" action="{% url 'news-delete' entry.slug %}">
|
<form method="POST" action="{% url 'news-delete' entry.slug %}">
|
||||||
|
|||||||
@@ -13,7 +13,10 @@
|
|||||||
<div class="py-8 md:mx-auto md:w-3/4">
|
<div class="py-8 md:mx-auto md:w-3/4">
|
||||||
<!-- Author or Moderator Actions -->
|
<!-- Author or Moderator Actions -->
|
||||||
<div class="space-x-3 text-right">
|
<div class="space-x-3 text-right">
|
||||||
{% if not entry.is_approved %}
|
{% if entry.deleted_at %}
|
||||||
|
<div class="mb-2 p-4 bg-red-600 text-white text-sm rounded-md">Entry deleted on {{ entry.deleted_at }} by {{ entry.deleted_by.display_name }}.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not entry.is_approved and not entry.deleted_at %}
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
{% if user_can_approve %}
|
{% if user_can_approve %}
|
||||||
<form method="POST" action="{% url 'news-approve' entry.slug %}">
|
<form method="POST" action="{% url 'news-approve' entry.slug %}">
|
||||||
@@ -28,10 +31,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user_can_delete %}
|
{% if user_can_delete and not entry.deleted_at%}
|
||||||
<a href="{% url 'news-delete' entry.slug %}" class="float-right inline-block items-center dark:text-white/50 dark:hover:text-orange text-sm ml-3 mt-2"><i class="fas fa-trash-alt"></i></a>
|
<a href="{% url 'news-delete' entry.slug %}" class="float-right inline-block items-center dark:text-white/50 dark:hover:text-orange text-sm ml-3 mt-2"><i class="fas fa-trash-alt"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user_can_edit %}
|
{% if user_can_edit and not entry.deleted_at %}
|
||||||
<a href="{% url 'news-update' entry.slug %}" class="float-right inline-block items-center dark:text-white/50 dark:hover:text-orange text-sm ml-3 mt-2"><i class="fas fa-edit"></i></a>
|
<a href="{% url 'news-update' entry.slug %}" class="float-right inline-block items-center dark:text-white/50 dark:hover:text-orange text-sm ml-3 mt-2"><i class="fas fa-edit"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user