Added Entry deleted flagging and ability for news authors and moderators to view marked items (#1882)

This commit is contained in:
Dave O'Connor
2025-12-04 18:33:12 -08:00
parent 240eafa5f1
commit 20510d2a18
8 changed files with 133 additions and 7 deletions

View File

@@ -25,7 +25,7 @@ def moderators():
def can_view(user, entry):
return (
entry.is_published
(entry.is_published and entry.deleted_at is None)
or user == entry.author
or (user is not None and user.has_perm("news.view_entry"))
)

View 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,
),
),
]

View File

@@ -93,6 +93,14 @@ class Entry(models.Model):
summary = models.TextField(
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()

View File

@@ -27,6 +27,7 @@ def make_entry(db):
kwargs.setdefault("approved_at", approved_at)
kwargs.setdefault("moderator", moderator)
kwargs.setdefault("publish_at", publish_at)
kwargs.setdefault("deleted_at", None)
kwargs.setdefault("title", "Admin User's Q3 Update")
entry = baker.make(model_class, **kwargs)
entry.author.set_password("password")

View File

@@ -1,4 +1,5 @@
import pytest
from datetime import datetime, timezone
from model_bakery import baker
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]
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

View File

@@ -83,7 +83,12 @@ class EntryListView(ListView):
context_object_name = "entry_list" # Ensure children use the same name
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()
for entry in result:
entry.display_publish_at = display_publish_at(entry.publish_at, right_now)
@@ -125,7 +130,12 @@ class EntryModerationListView(LoginRequiredMixin, UserPassesTestMixin, ListView)
paginate_by = None
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):
return can_approve(self.request.user)
@@ -373,6 +383,12 @@ class EntryDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
template_name = "news/confirm_delete.html"
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):
entry = self.get_object()
return entry.can_delete(self.request.user)

View File

@@ -5,7 +5,7 @@
<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>
<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>
<form method="POST" action="{% url 'news-delete' entry.slug %}">

View File

@@ -13,7 +13,10 @@
<div class="py-8 md:mx-auto md:w-3/4">
<!-- Author or Moderator Actions -->
<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">
{% if user_can_approve %}
<form method="POST" action="{% url 'news-approve' entry.slug %}">
@@ -28,10 +31,10 @@
{% endif %}
</div>
{% 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>
{% 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>
{% endif %}
</div>