Ground work for allowing moderation of News entries.

This commit is contained in:
Natalia
2023-05-16 22:50:15 -03:00
committed by nessita
parent 0ac2857bf4
commit 0b7d911dc4
8 changed files with 343 additions and 26 deletions

View File

@@ -8,6 +8,7 @@ from django.core.files import File as DjangoFile
# directories
pytest_plugins = [
"libraries.tests.fixtures",
"news.tests.fixtures",
"users.tests.fixtures",
"versions.tests.fixtures",
]

View File

@@ -4,6 +4,7 @@ from .models import Entry
class EntryAdmin(admin.ModelAdmin):
list_display = ["title", "author", "created_at", "approved_at", "publish_at"]
prepopulated_fields = {"slug": ["title"]}

View File

@@ -0,0 +1,31 @@
# Generated by Django 4.2 on 2023-05-17 01:16
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("news", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="entry",
name="approved_at",
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name="entry",
name="moderator",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_entries_set",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -8,6 +8,18 @@ from django.utils.timezone import now
User = get_user_model()
class EntryManager(models.Manager):
def get_queryset(self):
return (
super()
.get_queryset()
.annotate(
approved=models.Q(moderator__isnull=False, approved_at__lte=now())
)
.annotate(published=models.Q(publish_at__lte=now(), approved=True))
)
class Entry(models.Model):
"""A news entry.
@@ -17,33 +29,74 @@ class Entry(models.Model):
"""
class AlreadyApprovedError(Exception):
"""The entry cannot be approved again."""
APPROVED = "Approved"
PUBLISHED = "Published"
SUBMITTED = "Submitted"
slug = models.SlugField()
title = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
author = models.ForeignKey(User, on_delete=models.CASCADE)
moderator = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, related_name="moderated_entries_set"
)
external_url = models.URLField(blank=True, default="")
image = models.ImageField(upload_to="news", null=True, blank=True)
created_at = models.DateTimeField(default=now)
approved_at = models.DateTimeField(null=True)
publish_at = models.DateTimeField(default=now)
objects = EntryManager()
class Meta:
verbose_name_plural = "Entries"
def __str__(self):
return f"{self.title} by {self.author}"
@property
def status(self):
result = self.SUBMITTED
if self.moderator is not None and self.approved_at <= now():
result = self.APPROVED
if self.publish_at <= now():
result = self.PUBLISHED
return result
def approve(self, user):
"""Mark this entry as approved by the given `user`."""
if self.status != self.SUBMITTED:
raise self.AlreadyApprovedError()
self.moderator = user
self.approved_at = now()
self.save(update_fields=["moderator", "approved_at"])
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
return super().save(*args, **kwargs)
@property
def published(self):
return self.publish_at <= now()
def get_absolute_url(self):
return reverse("news-detail", args=[self.slug])
def can_view(self, user):
return (
self.status == self.PUBLISHED
or user == self.author
or (user is not None and user.has_perm("news.view_entry"))
)
def can_edit(self, user):
return (user == self.author and self.status == self.SUBMITTED) or (
user is not None and user.has_perm("news.change_entry")
)
def can_delete(self, user):
return user is not None and user.has_perm("news.delete_entry")
class BlogPost(Entry):
body = models.TextField()

28
news/tests/fixtures.py Normal file
View File

@@ -0,0 +1,28 @@
import datetime
import pytest
from django.utils.timezone import now
from model_bakery import baker
@pytest.fixture
def make_entry(db):
def _make_it(approved=True, published=True, **kwargs):
past = now() - datetime.timedelta(hours=1)
future = now() + datetime.timedelta(days=1)
if approved:
approved_at = past
moderator = baker.make("users.User")
else:
approved_at = None
moderator = None
if published:
publish_at = past
else:
publish_at = future
kwargs.setdefault("approved_at", approved_at)
kwargs.setdefault("moderator", moderator)
kwargs.setdefault("publish_at", publish_at)
return baker.make("Entry", **kwargs)
return _make_it

View File

@@ -1,5 +1,7 @@
import datetime
import pytest
from django.contrib.auth.models import Permission
from django.utils.timezone import now
from model_bakery import baker
@@ -23,21 +25,177 @@ def test_entry_slug_not_overwriten():
assert entry.slug == "different"
def test_entry_published():
entry = baker.make("Entry", publish_at=now())
assert entry.published is True
def test_entry_not_published():
entry = baker.make("Entry", publish_at=now() + datetime.timedelta(minutes=1))
assert entry.published is False
def test_entry_absolute_url():
entry = baker.make("Entry", slug="the-slug")
assert entry.get_absolute_url() == "/news/the-slug/"
def test_approve_entry(make_entry):
future = now() + datetime.timedelta(hours=1)
entry = make_entry(approved=False, publish_at=future)
assert entry.status == entry.SUBMITTED
user = baker.make("users.User")
before = now()
entry.approve(user)
after = now()
entry.refresh_from_db()
assert entry.moderator == user
# Avoid mocking `now()`, yet still ensure that the approval timestamp
# ocurred between `before` and `after`
assert entry.approved_at <= after
assert entry.approved_at >= before
assert entry.status == entry.APPROVED
def test_approve_already_approved_entry(make_entry):
past = now() - datetime.timedelta(minutes=1)
entry = make_entry(approved=True)
assert entry.status != entry.SUBMITTED
with pytest.raises(Entry.AlreadyApprovedError):
entry.approve(baker.make("users.User"))
def test_entry_permissions_author(make_entry):
entry = make_entry(approved=False)
author = entry.author
assert entry.can_view(author) is True
assert entry.can_edit(author) is True
assert entry.can_delete(author) is False
entry.approve(baker.make("users.User"))
assert entry.can_view(author) is True
assert entry.can_edit(author) is False
assert entry.can_delete(author) is False
def test_not_approved_entry_permissions_other_users(make_entry):
entry = make_entry(approved=False)
assert entry.can_view(None) is False
assert entry.can_edit(None) is False
assert entry.can_delete(None) is False
regular_user = baker.make("users.User")
assert entry.can_view(regular_user) is False
assert entry.can_edit(regular_user) is False
assert entry.can_delete(regular_user) is False
superuser = baker.make("users.User", is_superuser=True)
assert entry.can_view(superuser) is True
assert entry.can_edit(superuser) is True
assert entry.can_delete(superuser) is True
user_with_add_perm = baker.make("users.User")
user_with_add_perm.user_permissions.add(
Permission.objects.get(codename="add_entry")
)
assert entry.can_view(user_with_add_perm) is False
assert entry.can_edit(user_with_add_perm) is False
assert entry.can_delete(user_with_add_perm) is False
user_with_change_perm = baker.make("users.User")
user_with_change_perm.user_permissions.add(
Permission.objects.get(codename="change_entry")
)
assert entry.can_view(user_with_change_perm) is False
assert entry.can_edit(user_with_change_perm) is True
assert entry.can_delete(user_with_change_perm) is False
user_with_delete_perm = baker.make("users.User")
user_with_delete_perm.user_permissions.add(
Permission.objects.get(codename="delete_entry")
)
assert entry.can_view(user_with_delete_perm) is False
assert entry.can_edit(user_with_delete_perm) is False
assert entry.can_delete(user_with_delete_perm) is True
user_with_view_perm = baker.make("users.User")
user_with_view_perm.user_permissions.add(
Permission.objects.get(codename="view_entry")
)
assert entry.can_view(user_with_view_perm) is True
assert entry.can_edit(user_with_view_perm) is False
assert entry.can_delete(user_with_view_perm) is False
def test_approved_entry_permissions_other_users(make_entry):
entry = make_entry(approved=True)
assert entry.can_view(None) is True
assert entry.can_edit(None) is False
assert entry.can_delete(None) is False
regular_user = baker.make("users.User")
assert entry.can_view(regular_user) is True
assert entry.can_edit(regular_user) is False
assert entry.can_delete(regular_user) is False
superuser = baker.make("users.User", is_superuser=True)
assert entry.can_view(superuser) is True
assert entry.can_edit(superuser) is True
assert entry.can_delete(superuser) is True
user_with_add_perm = baker.make("users.User")
user_with_add_perm.user_permissions.add(
Permission.objects.get(codename="add_entry")
)
assert entry.can_view(user_with_add_perm) is True
assert entry.can_edit(user_with_add_perm) is False
assert entry.can_delete(user_with_add_perm) is False
user_with_change_perm = baker.make("users.User")
user_with_change_perm.user_permissions.add(
Permission.objects.get(codename="change_entry")
)
assert entry.can_view(user_with_change_perm) is True
assert entry.can_edit(user_with_change_perm) is True
assert entry.can_delete(user_with_change_perm) is False
user_with_delete_perm = baker.make("users.User")
user_with_delete_perm.user_permissions.add(
Permission.objects.get(codename="delete_entry")
)
assert entry.can_view(user_with_delete_perm) is True
assert entry.can_edit(user_with_delete_perm) is False
assert entry.can_delete(user_with_delete_perm) is True
user_with_view_perm = baker.make("users.User")
user_with_view_perm.user_permissions.add(
Permission.objects.get(codename="view_entry")
)
assert entry.can_view(user_with_view_perm) is True
assert entry.can_edit(user_with_view_perm) is False
assert entry.can_delete(user_with_view_perm) is False
def test_entry_manager_custom_queryset(make_entry):
moderator = baker.make("users.User")
future = now() + datetime.timedelta(hours=1)
past = now() - datetime.timedelta(hours=1)
entry_published = make_entry(approved=True, published=True)
entry_approved = make_entry(approved=True, published=False)
entry_not_approved = make_entry(approved=False)
entry_not_published = make_entry(approved=False, published=False)
assert list(Entry.objects.filter(approved=True).order_by("id")) == [
entry_published,
entry_approved,
]
assert list(Entry.objects.filter(approved=False).order_by("id")) == [
entry_not_approved,
entry_not_published,
]
assert list(Entry.objects.filter(published=True).order_by("id")) == [
entry_published
]
assert list(Entry.objects.filter(published=False).order_by("id")) == [
entry_approved,
entry_not_approved,
entry_not_published,
]
def test_blogpost():
blogpost = baker.make("BlogPost")
assert isinstance(blogpost, Entry)

View File

@@ -4,14 +4,19 @@ from django.utils.timezone import now
from model_bakery import baker
def test_entry_list(tp):
def test_entry_list(tp, make_entry):
"""List published news."""
yesterday_news = baker.make(
"Entry", title="old news", publish_at=now() - datetime.timedelta(days=1)
not_approved_news = make_entry(approved=False, title="needs moderation")
yesterday_news = make_entry(
approved=True, title="old news", publish_at=now() - datetime.timedelta(days=1)
)
today_news = baker.make("Entry", title="current news", publish_at=now().today())
tomorrow_news = baker.make(
"Entry", title="future news", publish_at=now() + datetime.timedelta(days=1)
today_news = make_entry(
approved=True, title="current news", publish_at=now().today()
)
tomorrow_news = make_entry(
approved=True,
title="future news",
publish_at=now() + datetime.timedelta(days=1),
)
response = tp.get("news")
@@ -25,14 +30,16 @@ def test_entry_list(tp):
assert n.get_absolute_url() in content
assert n.title in content
assert not_approved_news.get_absolute_url() not in content
assert not_approved_news.title not in content
assert tomorrow_news.get_absolute_url() not in content
assert tomorrow_news.title not in content
def test_news_detail(tp):
def test_news_detail(tp, make_entry):
"""Browse details for a given news entry."""
a_past_date = now() - datetime.timedelta(hours=10)
news = baker.make("Entry", publish_at=a_past_date)
news = make_entry(approved=True, publish_at=a_past_date)
url = tp.reverse("news-detail", news.slug)
response = tp.get(url)
@@ -48,7 +55,7 @@ def test_news_detail(tp):
# create an older news
older_date = a_past_date - datetime.timedelta(hours=1)
older = baker.make("Entry", publish_at=older_date)
older = make_entry(approved=True, publish_at=older_date)
response = tp.get(url)
tp.response_200(response)
@@ -61,7 +68,7 @@ def test_news_detail(tp):
# create a newer news, but still older than now so it's shown
newer_date = a_past_date + datetime.timedelta(hours=1)
assert newer_date < now()
newer = baker.make("Entry", publish_at=newer_date)
newer = make_entry(approved=True, publish_at=newer_date)
response = tp.get(url)
tp.response_200(response)
@@ -70,3 +77,33 @@ def test_news_detail(tp):
assert "newer entries" in content.lower()
assert "older entries" in content.lower()
assert newer.get_absolute_url() in content
def test_news_detail_404(tp):
"""No news is good news."""
url = tp.reverse("news-detail", "not-there")
response = tp.get(url)
tp.response_404(response)
def test_news_detail_404_if_not_published(tp, make_entry):
"""Details for a news entry are available if published or authored."""
news = make_entry(published=False)
response = tp.get(news.get_absolute_url())
tp.response_404(response)
# even if logged in, a regular user can not access the unpublished news
user = baker.make("users.User")
user.set_password("password")
user.save()
with tp.login(user):
response = tp.get(news.get_absolute_url())
tp.response_404(response)
# but the entry author can access it even if unpublished
user = news.author
user.set_password("password")
user.save()
with tp.login(user):
response = tp.get(news.get_absolute_url())
tp.response_200(response)

View File

@@ -1,3 +1,4 @@
from django.http import Http404
from django.views.generic import DetailView, ListView
from django.utils.timezone import now
@@ -7,7 +8,7 @@ from .models import Entry
def get_published_or_none(sibling_getter):
"""Helper method to get next/prev published sibling of a given entry."""
try:
result = sibling_getter(publish_at__lte=now())
result = sibling_getter(published=True)
except Entry.DoesNotExist:
result = None
return result
@@ -20,13 +21,20 @@ class EntryListView(ListView):
paginate_by = 10
def get_queryset(self):
return super().get_queryset().filter(publish_at__lte=now())
return super().get_queryset().filter(published=True)
class EntryDetailView(DetailView):
model = Entry
template_name = "news/detail.html"
def get_object(self, *args, **kwargs):
# Published news are available to anyone, otherwise to authors only
result = super().get_object(*args, **kwargs)
if not result.can_view(self.request.user):
raise Http404()
return result
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["next"] = get_published_or_none(self.object.get_next_by_publish_at)