diff --git a/config/celery.py b/config/celery.py index f999ceb4c..2c2099e3c 100644 --- a/config/celery.py +++ b/config/celery.py @@ -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"), + ) diff --git a/core/validators.py b/core/validators.py index 36f7830e9..62894a37a 100644 --- a/core/validators.py +++ b/core/validators.py @@ -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 ) @@ -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) diff --git a/news/forms.py b/news/forms.py index a80d3f761..97767c7dd 100644 --- a/news/forms.py +++ b/news/forms.py @@ -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 @@ -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"] @@ -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"] diff --git a/news/models.py b/news/models.py index ab8de75be..03af5c76e 100644 --- a/news/models.py +++ b/news/models.py @@ -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 @@ -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], ) created_at = models.DateTimeField(default=now) approved_at = models.DateTimeField(null=True, blank=True) diff --git a/news/tasks.py b/news/tasks.py index 2e0ac4965..e756c4f93 100644 --- a/news/tasks.py +++ b/news/tasks.py @@ -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__) @@ -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 @@ -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=}") diff --git a/news/tests/test_models.py b/news/tests/test_models.py index 28c9b53ca..ef3be61a5 100644 --- a/news/tests/test_models.py +++ b/news/tests/test_models.py @@ -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" ) entry.image = invalid_image # This should raise a ValidationError for file size diff --git a/news/views.py b/news/views.py index 292044f07..041899be7 100644 --- a/news/views.py +++ b/news/views.py @@ -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 @@ -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 ( @@ -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 @@ -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 @@ -392,11 +399,13 @@ def _v3_create_context(): ], "related_libraries_options": [ ("", "Select"), - ("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"), } @@ -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): @@ -535,9 +551,10 @@ 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."), @@ -545,7 +562,7 @@ def post(self, request, *args, **kwargs): 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 @@ -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) + 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 + 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", @@ -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) diff --git a/pages/blocks.py b/pages/blocks.py index ab4f7a797..1692ade66 100644 --- a/pages/blocks.py +++ b/pages/blocks.py @@ -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()), ] diff --git a/pages/models.py b/pages/models.py index 54462aa85..65585a799 100644 --- a/pages/models.py +++ b/pages/models.py @@ -224,7 +224,10 @@ def get_content(self): def get_context(self, request, *args, **kwargs): ctx = super().get_context(request, *args, **kwargs) pages = self.__class__.objects.live().order_by("-first_published_at") - next_objects = pages.filter(first_published_at__gt=self.first_published_at) + if self.live: + next_objects = pages.filter(first_published_at__gt=self.first_published_at) + else: + next_objects = pages ctx["next_post_items"] = [next_objects.last()] ctx["related_posts"] = pages.filter(content__0__type=self.stream_content_type)[ :3 diff --git a/templates/news/v3/create.html b/templates/news/v3/create.html index 6f7d9ca2d..078260f63 100644 --- a/templates/news/v3/create.html +++ b/templates/news/v3/create.html @@ -182,7 +182,7 @@