mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
Ground work for allowing moderation of News entries.
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
|
||||
|
||||
31
news/migrations/0002_entry_approved_at_entry_moderator.py
Normal file
31
news/migrations/0002_entry_approved_at_entry_moderator.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
28
news/tests/fixtures.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user