Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion news/acl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
)
Expand Down
32 changes: 32 additions & 0 deletions news/migrations/0012_entry_deleted_at_entry_deleted_by.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
8 changes: 8 additions & 0 deletions news/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ class AlreadyApprovedError(Exception):
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()

Expand Down
1 change: 1 addition & 0 deletions news/tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def _make_it(model_class="Entry", approved=True, published=True, **kwargs):
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")
Expand Down
66 changes: 66 additions & 0 deletions news/tests/test_acl.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from datetime import datetime, timezone
from model_bakery import baker

from ..acl import (
Expand Down Expand Up @@ -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
20 changes: 18 additions & 2 deletions news/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion templates/news/confirm_delete.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}">
Expand Down
9 changes: 6 additions & 3 deletions templates/news/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}">
Expand All @@ -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>
Expand Down