mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
395 lines
12 KiB
Python
395 lines
12 KiB
Python
from datetime import timedelta
|
|
from functools import partial
|
|
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
|
from django.contrib.humanize.templatetags import humanize
|
|
from django.contrib.messages.views import SuccessMessageMixin
|
|
from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden
|
|
from django.shortcuts import redirect, get_object_or_404
|
|
from django.template.defaultfilters import date as datefilter
|
|
from django.urls import reverse_lazy
|
|
from django.utils.http import url_has_allowed_host_and_scheme
|
|
from django.utils.timezone import now
|
|
from django.utils.translation import gettext as _
|
|
from django.views.generic import (
|
|
CreateView,
|
|
DeleteView,
|
|
DetailView,
|
|
ListView,
|
|
TemplateView,
|
|
UpdateView,
|
|
View,
|
|
)
|
|
from django.views.generic.detail import SingleObjectMixin
|
|
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData
|
|
|
|
from .acl import can_approve
|
|
from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION
|
|
from .forms import BlogPostForm, EntryForm, LinkForm, NewsForm, PollForm, VideoForm
|
|
from .models import BlogPost, Entry, Link, News, Poll, Video
|
|
from .notifications import (
|
|
send_email_news_approved,
|
|
send_email_news_needs_moderation,
|
|
send_email_news_posted,
|
|
)
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
def get_published_or_none(sibling_getter):
|
|
"""Helper method to get next/prev published sibling of a given entry."""
|
|
try:
|
|
result = sibling_getter(published=True)
|
|
except Entry.DoesNotExist:
|
|
result = None
|
|
return result
|
|
|
|
|
|
def display_publish_at(publish_at, since=None):
|
|
"""Show "Time since".
|
|
|
|
An hour ago (up to 24 hours)
|
|
3 days ago (up to 7 days)
|
|
June 13th, 2023 (after 7 days)
|
|
|
|
"""
|
|
if since is None:
|
|
since = now()
|
|
|
|
if publish_at > since:
|
|
publish_at = since
|
|
|
|
diff = since - publish_at
|
|
if diff.days >= 7:
|
|
return datefilter(publish_at, "M jS, Y")
|
|
|
|
if diff.days > 0:
|
|
truncated = since - timedelta(days=diff.days)
|
|
else:
|
|
hours = round(diff.seconds / 3600)
|
|
truncated = since - timedelta(hours=hours)
|
|
|
|
return humanize.naturaltime(truncated).replace("\xa0", " ")
|
|
|
|
|
|
class EntryListView(ListView):
|
|
model = Entry
|
|
template_name = "news/list.html"
|
|
ordering = ["-publish_at"]
|
|
paginate_by = None # XXX: use pagination in the template! Issue #377
|
|
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, deleted_at__isnull=True)
|
|
)
|
|
right_now = now()
|
|
for entry in result:
|
|
entry.display_publish_at = display_publish_at(entry.publish_at, right_now)
|
|
return result
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context["is_moderator"] = False
|
|
|
|
if self.request.user.is_authenticated:
|
|
context["is_moderator"] = can_approve(self.request.user)
|
|
return context
|
|
|
|
|
|
class BlogPostListView(EntryListView):
|
|
model = BlogPost
|
|
|
|
|
|
class LinkListView(EntryListView):
|
|
model = Link
|
|
|
|
|
|
class NewsListView(EntryListView):
|
|
model = News
|
|
|
|
|
|
class PollListView(EntryListView):
|
|
model = Poll
|
|
|
|
|
|
class VideoListView(EntryListView):
|
|
model = Video
|
|
|
|
|
|
class EntryModerationListView(LoginRequiredMixin, UserPassesTestMixin, ListView):
|
|
model = Entry
|
|
template_name = "news/moderation.html"
|
|
ordering = ["-publish_at"]
|
|
paginate_by = None
|
|
|
|
def get_queryset(self):
|
|
return (
|
|
super()
|
|
.get_queryset()
|
|
.select_related("author")
|
|
.filter(approved=False, deleted_at__isnull=True)
|
|
)
|
|
|
|
def test_func(self):
|
|
return can_approve(self.request.user)
|
|
|
|
|
|
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 and moderators 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)
|
|
next_url = self.request.GET.get("next")
|
|
if url_has_allowed_host_and_scheme(next_url, allowed_hosts=None):
|
|
context["next_url"] = next_url
|
|
context["next"] = get_published_or_none(self.object.get_next_by_publish_at)
|
|
context["prev"] = get_published_or_none(self.object.get_previous_by_publish_at)
|
|
if self.object.tag:
|
|
category_kwarg = {f"{self.object.tag}__isnull": False}
|
|
else:
|
|
category_kwarg = {}
|
|
context["next_in_category"] = get_published_or_none(
|
|
partial(self.object.get_next_by_publish_at, **category_kwarg)
|
|
)
|
|
context["prev_in_category"] = get_published_or_none(
|
|
partial(self.object.get_previous_by_publish_at, **category_kwarg)
|
|
)
|
|
context["user_can_approve"] = self.object.can_approve(self.request.user)
|
|
context["user_can_edit"] = self.object.can_edit(self.request.user)
|
|
context["user_can_delete"] = self.object.can_delete(self.request.user)
|
|
return context
|
|
|
|
|
|
class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView): ...
|
|
|
|
|
|
class EntryModerationMagicApproveView(View):
|
|
"""Approve a news entry without requiring moderator login."""
|
|
|
|
def get(self, request, token, *args, **kwargs):
|
|
serializer = URLSafeTimedSerializer(settings.SECRET_KEY)
|
|
try:
|
|
data = serializer.loads(
|
|
token, salt=NEWS_APPROVAL_SALT, max_age=MAGIC_LINK_EXPIRATION
|
|
)
|
|
entry_slug = data["entry_slug"]
|
|
moderator_id = data["moderator_id"]
|
|
moderator = User.objects.get(id=moderator_id)
|
|
except SignatureExpired:
|
|
message = _("This link has expired.")
|
|
if not request.user.is_authenticated:
|
|
message += _(" Please login to continue.")
|
|
messages.warning(request, message)
|
|
return redirect(reverse_lazy("news-moderate"))
|
|
except (BadData, User.DoesNotExist):
|
|
return HttpResponseForbidden("Invalid magic link.")
|
|
|
|
entry = get_object_or_404(Entry, slug=entry_slug)
|
|
|
|
try:
|
|
entry.approve(moderator)
|
|
messages.success(request, _("This entry has been approved."))
|
|
except Entry.AlreadyApprovedError:
|
|
messages.warning(request, _("This entry has already been approved."))
|
|
|
|
return redirect(entry)
|
|
|
|
|
|
class EntryCreateView(LoginRequiredMixin, SuccessMessageMixin, CreateView):
|
|
model = None
|
|
form_class = None
|
|
template_name = "news/form.html"
|
|
add_label = None
|
|
add_url_name = None
|
|
success_message = _("The news entry was successfully created.")
|
|
|
|
def form_valid(self, form):
|
|
form.instance.author = self.request.user
|
|
result = super().form_valid(form)
|
|
if not form.instance.is_approved:
|
|
send_email_news_needs_moderation(request=self.request, entry=form.instance)
|
|
else:
|
|
send_email_news_posted(request=self.request, entry=form.instance)
|
|
return result
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context["add_label"] = self.add_label
|
|
context["add_url_name"] = self.add_url_name
|
|
context["cancel_url"] = reverse_lazy("news")
|
|
return context
|
|
|
|
|
|
class BlogPostCreateView(EntryCreateView):
|
|
model = BlogPost
|
|
form_class = BlogPostForm
|
|
add_label = _("Create Blog Post")
|
|
add_url_name = "news-blogpost-create"
|
|
|
|
|
|
class LinkCreateView(EntryCreateView):
|
|
model = Link
|
|
form_class = LinkForm
|
|
add_label = _("Create Link")
|
|
add_url_name = "news-link-create"
|
|
|
|
|
|
class NewsCreateView(EntryCreateView):
|
|
model = News
|
|
form_class = NewsForm
|
|
add_label = _("Create News")
|
|
add_url_name = "news-news-create"
|
|
|
|
|
|
class PollCreateView(EntryCreateView):
|
|
model = Poll
|
|
form_class = PollForm
|
|
add_label = _("Create a Poll")
|
|
add_url_name = "news-poll-create"
|
|
|
|
|
|
class VideoCreateView(EntryCreateView):
|
|
model = Video
|
|
form_class = VideoForm
|
|
add_label = _("Upload a Video")
|
|
add_url_name = "news-video-create"
|
|
|
|
|
|
class AllTypesCreateView(LoginRequiredMixin, TemplateView):
|
|
template_name = "news/create.html"
|
|
http_method_names = ["get"] # This is a "create news" multiplexer (by news type)
|
|
|
|
@staticmethod
|
|
def item_params(view):
|
|
return {
|
|
"form": view.form_class(),
|
|
"model_name": view.model.__name__,
|
|
"add_label": view.add_label,
|
|
"add_url_name": view.add_url_name,
|
|
}
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
"""User must have a profile photo and a name to post an entry."""
|
|
if request.user.is_authenticated:
|
|
missing_data = []
|
|
|
|
if not request.user.display_name:
|
|
missing_data.append("your name")
|
|
|
|
if not request.user.profile_image:
|
|
missing_data.append("a profile photo")
|
|
|
|
if missing_data:
|
|
messages.warning(
|
|
request, f"Please add {' and '.join(missing_data)} first."
|
|
)
|
|
return redirect("profile-account")
|
|
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
items = [
|
|
self.item_params(BlogPostCreateView),
|
|
self.item_params(LinkCreateView),
|
|
self.item_params(NewsCreateView),
|
|
self.item_params(VideoCreateView),
|
|
]
|
|
# Only superusers and moderators can create Polls
|
|
if can_approve(self.request.user):
|
|
items.append(self.item_params(PollCreateView))
|
|
context["items"] = items
|
|
return context
|
|
|
|
|
|
class EntryApproveView(
|
|
LoginRequiredMixin, UserPassesTestMixin, SingleObjectMixin, View
|
|
):
|
|
model = Entry
|
|
http_method_names = ["post"]
|
|
|
|
def test_func(self):
|
|
entry = self.get_object()
|
|
return entry.can_approve(self.request.user)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
entry = self.get_object()
|
|
try:
|
|
entry.approve(user=self.request.user)
|
|
except Entry.AlreadyApprovedError:
|
|
messages.error(request, _("The entry was already approved."))
|
|
else:
|
|
messages.success(request, _("The entry was successfully approved."))
|
|
send_email_news_approved(request=request, entry=entry)
|
|
send_email_news_posted(request=request, entry=entry)
|
|
|
|
next_url = request.POST.get("next")
|
|
if next_url is None or not url_has_allowed_host_and_scheme(
|
|
next_url, allowed_hosts=None
|
|
):
|
|
next_url = entry.get_absolute_url()
|
|
return HttpResponseRedirect(next_url)
|
|
|
|
|
|
class EntryUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
|
|
model = Entry
|
|
template_name = "news/form.html"
|
|
|
|
def test_func(self):
|
|
entry = self.get_object()
|
|
return entry.can_edit(self.request.user)
|
|
|
|
def get_form_class(self):
|
|
if self.object.is_blogpost:
|
|
result = BlogPostForm
|
|
elif self.object.is_link:
|
|
result = LinkForm
|
|
elif self.object.is_news:
|
|
result = NewsForm
|
|
elif self.object.is_poll:
|
|
result = PollForm
|
|
elif self.object.is_video:
|
|
result = VideoForm
|
|
else:
|
|
result = EntryForm
|
|
return result
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context["cancel_url"] = reverse_lazy(
|
|
"news-detail", kwargs={"slug": self.object.slug}
|
|
)
|
|
return context
|
|
|
|
|
|
class EntryDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
|
|
model = Entry
|
|
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)
|