Skip to content
7 changes: 7 additions & 0 deletions config/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,10 @@ def setup_periodic_tasks(sender, **kwargs):
crontab(day_of_week="mon", hour=5, minute=15),
app.signature("core.tasks.refresh_popular_search_terms"),
)

# Publish approved Wagtail pages whose go_live_at has passed.
# Executes once an hour.
sender.add_periodic_task(
crontab(hour="*/1", minute=0),
app.signature("news.tasks.publish_scheduled_pages"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
5 changes: 4 additions & 1 deletion core/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def __init__(self, max_size):
self.max_size = max_size

def __call__(self, value):
if value.size > self.max_size:
if value.size >= self.max_size:
raise ValidationError(
f"File too large. Size should not exceed {self.max_size / 1024 / 1024} MB." # noqa
)
Expand All @@ -46,5 +46,8 @@ def __call__(self, value):
# 1 MB max file size
max_file_size_validator = MaxFileSizeValidator(max_size=1 * 1024 * 1024)

# 5 MB max file size
downscale_image_file_size_validator = MaxFileSizeValidator(max_size=5 * 1024 * 1024)

# 50 MB allowed for certain large files - to be used on staff-only fields
large_file_max_size_validator = MaxFileSizeValidator(max_size=50 * 1024 * 1024)
18 changes: 18 additions & 0 deletions news/forms.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from django import forms

from core.validators import downscale_image_file_size_validator, max_file_size_validator

from .models import BlogPost, Entry, Link, News, Poll, Video


class EntryForm(forms.ModelForm):
title = forms.CharField(widget=forms.TextInput(attrs={"size": 100}))
image = forms.ImageField(validators=[max_file_size_validator], required=False)

class Meta:
model = Entry
Expand Down Expand Up @@ -48,16 +52,22 @@ class Meta:

# v3-only forms: the v3 create page also captures the AI-assisted `summary`.
class V3BlogPostForm(BlogPostForm):
image = forms.ImageField(validators=[downscale_image_file_size_validator])

class Meta(BlogPostForm.Meta):
fields = ["title", "publish_at", "content", "summary", "image"]


class V3NewsForm(NewsForm):
image = forms.ImageField(validators=[downscale_image_file_size_validator])

class Meta(NewsForm.Meta):
fields = ["title", "publish_at", "content", "summary", "image"]


class V3LinkForm(LinkForm):
image = forms.ImageField(validators=[downscale_image_file_size_validator])

class Meta(LinkForm.Meta):
fields = ["title", "publish_at", "external_url", "summary", "image"]

Expand All @@ -72,3 +82,11 @@ class VideoForm(EntryForm):
class Meta:
model = Video
fields = ["title", "publish_at", "external_url", "image"]


class V3VideoForm(EntryForm):
image = forms.ImageField(validators=[downscale_image_file_size_validator])

class Meta:
model = Video
fields = ["title", "publish_at", "external_url", "summary", "image"]
4 changes: 2 additions & 2 deletions news/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
from core.validators import (
attachment_validator,
image_validator,
max_file_size_validator,
large_file_max_size_validator,
downscale_image_file_size_validator,
)

from . import acl
Expand Down Expand Up @@ -112,7 +112,7 @@ class AlreadyApprovedError(Exception):
upload_to="news/%Y/%m/",
null=True,
blank=True,
validators=[image_validator, max_file_size_validator],
validators=[image_validator, downscale_image_file_size_validator],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
created_at = models.DateTimeField(default=now)
approved_at = models.DateTimeField(null=True, blank=True)
Expand Down
18 changes: 15 additions & 3 deletions news/tasks.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from celery import shared_task
from textwrap import dedent
from openai import OpenAI, OpenAIError
import requests
import structlog

from django.core.management import call_command

from config.celery import app
from config.settings import OPENROUTER_API_KEY, OPENROUTER_URL, SUMMARIZATION_MODEL
from news.constants import CONTENT_SUMMARIZATION_THRESHOLD
from news.helpers import UnsafeURLError, extract_article, safe_get, extract_content
from news.helpers import UnsafeURLError, extract_article, safe_get
from news.utils import set_video_thumbnail

logger = structlog.get_logger(__name__)
Expand Down Expand Up @@ -108,6 +111,15 @@ def generate_summary(
return summary


@shared_task
def publish_scheduled_pages():
"""
Publish Wagtail pages whose approved go_live_at has passed
(and unpublish pages past their expire_at).
"""
call_command("publish_scheduled")


@app.task(bind=True, max_retries=3, autoretry_for=(OpenAIError,))
def summarize_content(
self, content: str, title: str, model: str, max_length: int = 256
Expand Down Expand Up @@ -307,11 +319,11 @@ def set_summary_for_link_page(pk: int):
external_url = page.external_url
try:
logger.info(f"Fetching content from {external_url=} for entry.{pk=}")
response = requests.get(external_url, timeout=10)
response = safe_get(external_url, timeout=10)
response.raise_for_status()
markup = response.text
logger.debug(f"Fetched {len(markup)=} for entry.{pk=}...")
content = extract_content(markup)
_title, content = extract_article(markup)
logger.info(f"extracted content from {external_url=}, {markup[:100]=}")
except requests.RequestException as e:
logger.error(f"Error fetching content from {external_url=}: {e=}")
Expand Down
6 changes: 3 additions & 3 deletions news/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,15 @@ def test_entry_model_image_file_size(tp):
entry = Entry.objects.create(title="😀 Foo Bar Baz!@! +", author=author)

valid_image = SimpleUploadedFile(
"test.jpg", b"a" * (1 * 1024 * 1024 - 1), content_type="image/jpeg"
"test.jpg", b"a" * (5 * 1024 * 1024 - 1), content_type="image/jpeg"
)
entry.image = valid_image
# This should not raise any errors
entry.full_clean()

# This should fail (just over 1MB)
# This should fail (just over 5MB)
invalid_image = SimpleUploadedFile(
"too_large.jpg", b"a" * (1 * 1024 * 1024 + 1), content_type="image/jpeg"
"too_large.jpg", b"a" * (5 * 1024 * 1024 + 1), content_type="image/jpeg"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
entry.image = invalid_image
# This should raise a ValidationError for file size
Expand Down
128 changes: 104 additions & 24 deletions news/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import structlog

from django.conf import settings
from django.db import IntegrityError
from django.forms import ValidationError
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
Expand Down Expand Up @@ -37,8 +37,13 @@
from django.views.generic.detail import SingleObjectMixin
from django.views.decorators.http import require_POST
from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData
from wagtail.blocks import Block
from wagtail.images.models import Image

from core.mixins import V3Mixin
from pages.blocks import NEWS_BLOCK, BLOG_BLOCK, LINK_BLOCK, VIDEO_BLOCK
from pages.models import PostPage, PostIndexPage
from pages.mixins import ContentTag
from users.profile_cards import user_profile_card
from .acl import can_approve
from .constants import (
Expand All @@ -53,10 +58,11 @@
LinkForm,
NewsForm,
PollForm,
VideoForm,
V3BlogPostForm,
V3LinkForm,
V3NewsForm,
VideoForm,
V3VideoForm,
)
from .models import BlogPost, Entry, Link, News, Poll, Video
from .services import news_type_label
Expand All @@ -67,6 +73,7 @@
send_email_news_needs_moderation,
send_email_news_posted,
)
from news.utils import downsize_uploaded_image

from libraries.models import Library

Expand Down Expand Up @@ -392,11 +399,13 @@ def _v3_create_context():
],
"related_libraries_options": [
("", "Select"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we remove this ("", "Select") option as well?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to maintain the Select option, or otherwise have a "clear selection" option, just in case the post is actually unrelated to any library and one is selected in error.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I've gone ahead and added the image scaling, thanks for the catch!

("asio", "Asio"),
("beast", "Beast"),
("filesystem", "Filesystem"),
("json", "JSON"),
("spirit", "Spirit"),
]
+ [
(
library.slug,
library.name,
)
for library in Library.objects.all().order_by("name")
],
"publish_at_initial": localtime(now()).strftime("%Y-%m-%dT%H:%M"),
}
Expand Down Expand Up @@ -514,11 +523,18 @@ class V3AllTypesCreateView(V3Mixin, AllTypesCreateView):
v3_template_name = "news/v3/create.html"
http_method_names = ["get", "post"]

_POST_BLOCK_MAP: dict[str, tuple[str, Block]] = {
"blog": BLOG_BLOCK,
"news": NEWS_BLOCK,
"link": LINK_BLOCK,
"video": VIDEO_BLOCK,
}

_POST_TYPE_MAP = {
"blog": (BlogPost, V3BlogPostForm),
"news": (News, V3NewsForm),
"link": (Link, V3LinkForm),
"video": (Video, VideoForm),
"blog": V3BlogPostForm,
"news": V3NewsForm,
"link": V3LinkForm,
"video": V3VideoForm,
}

def dispatch(self, request, *args, **kwargs):
Expand All @@ -535,17 +551,18 @@ def get_context_data(self, **kwargs):

def post(self, request, *args, **kwargs):
post_type = request.POST.get("post_type", "")
type_config = self._POST_TYPE_MAP.get(post_type)
block_config = self._POST_BLOCK_MAP.get(post_type, None)
form_class = self._POST_TYPE_MAP.get(post_type)

if type_config is None:
if block_config is None or form_class is None:
messages.error(
request,
_("Invalid post type selected. Please choose a valid post type."),
)
context = self.get_context_data()
return self.render_to_response(context)

model_class, form_class = type_config
block_name, block_class = block_config

# The v3 create page has two Description textareas — `description` for
# Blog/News and `link_description` for Link/Video — so the two don't
Expand All @@ -561,10 +578,67 @@ def post(self, request, *args, **kwargs):
form = form_class(post_data, request.FILES)

if form.is_valid():
form.instance.author = request.user
cleaned_data = form.cleaned_data
# Since the PostIndexPage is limited to one, we can just grab the first
index_page = PostIndexPage.objects.first()
if not index_page:
messages.error(
request,
_(
"An internal database error has occurred. Please contact an admin."
),
)
context = self.get_context_data()
return self.render_to_response(context)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
entry = form.save()
except IntegrityError as e:
page = PostPage()
page.owner = request.user
page.title = cleaned_data.get("title")
page.summary = cleaned_data.get("summary", "")
page.go_live_at = cleaned_data.get("publish_at")
page.content = [
(
block_name,
cleaned_data.get("content") or cleaned_data.get("external_url"),
)
]
page.live = False
if image := form.cleaned_data.get("image"):
if image.size >= settings.DOWNSCALE_IMAGE_THRESHOLD:
image = downsize_uploaded_image(image)
wagtail_image = Image.objects.create(
title=image.name,
file=image,
)
page.image = wagtail_image
Comment thread
coderabbitai[bot] marked this conversation as resolved.
tags = []
if related_library := post_data.get("related_libraries"):
try:
lib = Library.objects.get(slug=related_library)
except Library.DoesNotExist:
messages.error(
request,
_(
"That related library does not exist, please select another."
),
)
context = self.get_context_data()
return self.render_to_response(context)

tag, created = ContentTag.objects.get_or_create(
slug=lib.slug,
defaults={
"name": lib.name,
},
)
tags.append(tag)
index_page.add_child(instance=page)
if tags:
page.tags.add(*tags)
page.save_revision(user=request.user)
page.get_workflow().start(obj=page, user=request.user)
except ValidationError as e:
if "slug" in str(e):
form.add_error(
"title",
Expand All @@ -574,16 +648,22 @@ def post(self, request, *args, **kwargs):
form.add_error(
None, "An unexpected error occurred. Please try again."
)
messages.error(
request,
_(
"Something went wrong — your draft is saved, so give it another try."
),
)
context = self.get_context_data(form=form, post_type_selected=post_type)
return self.render_to_response(context)

if not entry.is_approved:
send_email_news_needs_moderation(request=request, entry=entry)
else:
send_email_news_posted(request=request, entry=entry)

messages.success(request, _("The news entry was successfully created."))
return redirect(entry)
messages.success(
request,
_(
"Your post has been submitted. It'll be reviewed before it goes live, you will receive updates via email."
),
)
return redirect(index_page.url)

context = self.get_context_data(form=form, post_type_selected=post_type)
return self.render_to_response(context)
Expand Down
14 changes: 10 additions & 4 deletions pages/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ class PollBlock(StreamBlock):
poll_choice = CharBlock(max_length=200)


BLOG_BLOCK = ("blog", MarkdownBlock())
NEWS_BLOCK = ("news", MarkdownBlock())
LINK_BLOCK = ("url", URLBlock())
VIDEO_BLOCK = ("video", EmbedBlock(label="Video"))


POST_BLOCKS = [
("rich_text", RichTextBlock()),
("blog", MarkdownBlock()),
("news", MarkdownBlock()),
("url", URLBlock()),
("video", EmbedBlock(label="Video")),
BLOG_BLOCK,
NEWS_BLOCK,
LINK_BLOCK,
VIDEO_BLOCK,
("poll", PollBlock()),
]
Loading
Loading