From 03f91d1757f95a812525031a01b596091f809060 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:56:46 +0000 Subject: [PATCH 1/2] feat: allow admins to pin posts to the top of a project feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can now pin/unpin a post to the top of its default project's (tournament/community) feed via the post "ยทยทยท" dropdown menu. Pinned posts render a pin icon on their feed card. - Add Post.is_pinned field (+ migration) - Add ObjectPermission.can_pin_post (admin only) - Add pin_post/unpin_post services and a toggle-pin endpoint - Order pinned posts first in a single-project feed, scoped to the post's default project (reposts are unaffected) - Serialize is_pinned and wire up the frontend dropdown action + icon Closes #3223 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01T4pYzdkyDaxzUry6iQP684 --- front_end/messages/en.json | 4 + front_end/src/app/(main)/questions/actions.ts | 6 ++ .../post_actions/post_dropdown_menu.tsx | 25 ++++++- .../post_card/basic_post_card/index.tsx | 11 ++- .../src/services/api/posts/posts.server.ts | 9 +++ front_end/src/types/post.ts | 1 + posts/migrations/0033_post_is_pinned.py | 18 +++++ posts/models.py | 3 + posts/serializers.py | 1 + posts/services/common.py | 18 +++++ posts/services/feed.py | 69 ++++++++++++++++- posts/urls.py | 5 ++ posts/views.py | 29 ++++++++ projects/permissions.py | 9 +++ .../test_posts/test_services/test_feed.py | 74 +++++++++++++++++++ tests/unit/test_posts/test_views.py | 34 +++++++++ 16 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 posts/migrations/0033_post_is_pinned.py diff --git a/front_end/messages/en.json b/front_end/messages/en.json index a14dc09d11..5a5b53f7b4 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1114,6 +1114,10 @@ "commentPinned": "Comment pinned", "unpinComment": "Unpin", "commentUnpinned": "Comment unpinned", + "pinInProject": "Pin in Project", + "unpinFromProject": "Unpin from Project", + "postPinned": "Post pinned", + "postUnpinned": "Post unpinned", "finePrintDescription": "Optional: Use the fine print for any sort of lawyerly details which don't need to be prominently displayed.", "createQuestion": "create question", "submitAQuestion": "Submit a Question", diff --git a/front_end/src/app/(main)/questions/actions.ts b/front_end/src/app/(main)/questions/actions.ts index 9d958eb28f..8fc504d871 100644 --- a/front_end/src/app/(main)/questions/actions.ts +++ b/front_end/src/app/(main)/questions/actions.ts @@ -298,6 +298,12 @@ export async function changePostActivityBoost( return await ServerPostsApi.changePostActivityBoost(postId, direction); } +export async function togglePinPost(postId: number, pin: boolean) { + const post = await ServerPostsApi.togglePinPost(postId, pin); + revalidatePath(`/questions/${postId}`); + return post; +} + export async function removeRelatedArticle(articleId: number) { await ServerPostsApi.removeRelatedArticle(articleId); revalidateTag("related-articles", "max"); diff --git a/front_end/src/components/post_actions/post_dropdown_menu.tsx b/front_end/src/components/post_actions/post_dropdown_menu.tsx index bb8e1e74a5..25e8b79387 100644 --- a/front_end/src/components/post_actions/post_dropdown_menu.tsx +++ b/front_end/src/components/post_actions/post_dropdown_menu.tsx @@ -10,7 +10,10 @@ import DataRequestModal from "@/app/(main)/questions/[id]/components/download_qu import PostDestructiveActionModal, { PostDestructiveActionModalProps, } from "@/app/(main)/questions/[id]/components/post_destructive_action_modal"; -import { changePostActivityBoost } from "@/app/(main)/questions/actions"; +import { + changePostActivityBoost, + togglePinPost, +} from "@/app/(main)/questions/actions"; import QuestionResolutionModal from "@/components/forecast_maker/resolution/resolution_modal"; import QuestionUnresolveModal from "@/components/forecast_maker/resolution/unresolve_modal"; import Button from "@/components/ui/button"; @@ -101,6 +104,21 @@ export const PostDropdownMenu: FC = ({ post, button, hideShare }) => { [post.id, t] ); + const [isPinned, setIsPinned] = useState(!!post.is_pinned); + const togglePin = useCallback(() => { + const nextPinned = !isPinned; + setIsPinned(nextPinned); + togglePinPost(post.id, nextPinned) + .then(() => { + toast(nextPinned ? t("postPinned") : t("postUnpinned")); + router.refresh(); + }) + .catch(() => { + // Revert optimistic update on failure + setIsPinned(!nextPinned); + }); + }, [isPinned, post.id, router, t]); + const [isResolutionModalOpen, setIsResolutionModalOpen] = useState(false); const [isUnresolveModalOpen, setIsUnresolveModalOpen] = useState(false); @@ -252,6 +270,11 @@ export const PostDropdownMenu: FC = ({ post, button, hideShare }) => { // Include if user is admin ...(isAdmin ? [ + { + id: "togglePin", + name: isPinned ? t("unpinFromProject") : t("pinInProject"), + onClick: togglePin, + }, { id: "deleteQuestion", name: t("delete"), diff --git a/front_end/src/components/post_card/basic_post_card/index.tsx b/front_end/src/components/post_card/basic_post_card/index.tsx index d9b24e7d7c..6967263bbc 100644 --- a/front_end/src/components/post_card/basic_post_card/index.tsx +++ b/front_end/src/components/post_card/basic_post_card/index.tsx @@ -1,5 +1,7 @@ "use client"; +import { faThumbtack } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; import Link from "next/link"; import { FC, PropsWithChildren } from "react"; @@ -56,7 +58,7 @@ const BasicPostCard: FC> = ({ )}
> = ({ }[borderColor] )} > + {post.is_pinned && ( + + )} { + return this.post(`/posts/${postId}/toggle-pin/`, { + pin, + }); + } + async updateSubscriptions(postId: number, subscriptions: PostSubscription[]) { return this.post( `/posts/${postId}/subscriptions/`, diff --git a/front_end/src/types/post.ts b/front_end/src/types/post.ts index ca8c2f8d3a..2545769fde 100644 --- a/front_end/src/types/post.ts +++ b/front_end/src/types/post.ts @@ -154,6 +154,7 @@ type BasePost = { status: PostStatus; resolved: boolean; user_permission: ProjectPermissions; + is_pinned?: boolean; comment_count?: number; forecasts_count?: number; subscriptions?: Array; diff --git a/posts/migrations/0033_post_is_pinned.py b/posts/migrations/0033_post_is_pinned.py new file mode 100644 index 0000000000..ff8d480d47 --- /dev/null +++ b/posts/migrations/0033_post_is_pinned.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-08-07 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("posts", "0032_post_news_hotness"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="is_pinned", + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/posts/models.py b/posts/models.py index 169f206c5a..a8337eaa06 100644 --- a/posts/models.py +++ b/posts/models.py @@ -615,6 +615,9 @@ class PostStatusChange(models.TextChoices): # Whether we should display Post/Notebook on the homepage show_on_homepage = models.BooleanField(default=False, db_index=True) + + # Whether an admin pinned this post to the top of its default project's feed + is_pinned = models.BooleanField(default=False, db_index=True) html_metadata_json = models.JSONField( help_text=( "Custom JSON for HTML meta tags. Supported fields are: title, description, " diff --git a/posts/serializers.py b/posts/serializers.py index 0c5e4b4552..a5f2505106 100644 --- a/posts/serializers.py +++ b/posts/serializers.py @@ -101,6 +101,7 @@ class Meta: "open_time", "nr_forecasters", "html_metadata_json", + "is_pinned", ) def get_author_username(self, obj: Post): diff --git a/posts/services/common.py b/posts/services/common.py index 79098f0f2e..8f30c8380c 100644 --- a/posts/services/common.py +++ b/posts/services/common.py @@ -521,6 +521,24 @@ def soft_delete_post(post: Post): delete_scheduled_post_notifications(post) +def pin_post(post: Post): + """ + Pins a post to the top of its default project's feed. + """ + + post.is_pinned = True + post.save(update_fields=["is_pinned"]) + + return post + + +def unpin_post(post: Post): + post.is_pinned = False + post.save(update_fields=["is_pinned"]) + + return post + + def get_posts_staff_users( posts: Iterable[Post], ) -> dict[Post, dict[int, ObjectPermission]]: diff --git a/posts/services/feed.py b/posts/services/feed.py index b2e8f950fa..83922dd59f 100644 --- a/posts/services/feed.py +++ b/posts/services/feed.py @@ -1,7 +1,17 @@ from datetime import timedelta from typing import Iterable -from django.db.models import Q, QuerySet, Exists, Max, OuterRef +from django.db.models import ( + Q, + QuerySet, + Exists, + Max, + OuterRef, + Case, + When, + Value, + IntegerField, +) from django.utils import timezone from rest_framework.exceptions import ValidationError, PermissionDenied @@ -21,6 +31,37 @@ from utils.serializers import parse_order_by +def _get_pinned_scope_project_id( + tournaments: list[Project] = None, + community: Project = None, + default_project_id: int | Project = None, +) -> int | None: + """ + Pinned posts are shown at the top of their default project's feed. + + This resolves the single project a feed is scoped to (a tournament, + a community or an explicit default project). Returns None when the feed + isn't scoped to exactly one project (e.g. the main feed or search across + multiple tournaments), in which case pinning shouldn't affect ordering. + """ + + project = None + + if tournaments and len(tournaments) == 1: + project = tournaments[0] + elif community: + project = community + elif default_project_id: + project = default_project_id + + if project is None: + return None + + # `default_project_id` may be passed either as a Project instance + # (resolved by the serializer) or as a raw id + return getattr(project, "pk", project) + + def get_posts_feed( # noqa: C901 qs: Post.objects = None, user: User = None, @@ -336,6 +377,32 @@ def get_posts_feed( # noqa: C901 .only("pk") ) + # Pinned posts are shown at the top of their default project's feed. + # Only applied to a feed scoped to a single project, and skipped when + # results are ordered by relevance (search / similar posts). + pinned_project_id = _get_pinned_scope_project_id( + tournaments=tournaments, + community=community, + default_project_id=default_project_id, + ) + if pinned_project_id is not None and not search and not similar_to_post_id: + qs = qs.annotate( + is_pinned_first=Case( + When( + is_pinned=True, + default_project_id=pinned_project_id, + then=Value(1), + ), + default=Value(0), + output_field=IntegerField(), + ) + ).order_by( + build_order_by("is_pinned_first", True), + build_order_by(order_type, order_desc), + ) + + return qs.distinct("is_pinned_first", "id", order_type).only("pk") + qs = qs.order_by(build_order_by(order_type, order_desc)) return qs.distinct("id", order_type).only("pk") diff --git a/posts/urls.py b/posts/urls.py index b98848e670..24fcb572ad 100644 --- a/posts/urls.py +++ b/posts/urls.py @@ -15,6 +15,11 @@ ), path("posts//", views.post_detail, name="post-detail"), path("posts//boost/", views.activity_boost_api_view, name="post-boost"), + path( + "posts//toggle-pin/", + views.post_toggle_pin_api_view, + name="post-toggle-pin", + ), path("posts//repost/", views.repost_api_view, name="post-repost"), path("posts//approve/", views.post_approve_api_view, name="post-approve"), path( diff --git a/posts/views.py b/posts/views.py index 62ce4fecb5..2f2a495375 100644 --- a/posts/views.py +++ b/posts/views.py @@ -39,6 +39,8 @@ trigger_update_post_translations, make_repost, vote_post, + pin_post, + unpin_post, ) from posts.services.feed import get_posts_feed, get_similar_posts from posts.services.hotness import ( @@ -479,6 +481,33 @@ def activity_boost_api_view(request, pk): ) +@api_view(["POST"]) +def post_toggle_pin_api_view(request, pk): + """ + Pin/Unpin a post to the top of its default project's feed (admins only) + """ + + pin = serializers.BooleanField(allow_null=True).run_validation( + request.data.get("pin") + ) + + post = get_object_or_404(Post, pk=pk) + + # Check permissions + permission = get_post_permission_for_user(post, user=request.user) + ObjectPermission.can_pin_post(permission, raise_exception=True) + + if pin: + pin_post(post) + else: + unpin_post(post) + + return Response( + serialize_post(post, current_user=request.user), + status=status.HTTP_200_OK, + ) + + @api_view(["POST", "PUT"]) def post_subscriptions_create(request, pk): """ diff --git a/projects/permissions.py b/projects/permissions.py index 412a3f2e07..51571dc3e9 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -97,6 +97,15 @@ def can_pin_comment(cls, permission: Self, raise_exception=False): return can + @classmethod + def can_pin_post(cls, permission: Self, raise_exception=False): + can = permission in (cls.ADMIN,) + + if raise_exception and not can: + raise PermissionDenied("You do not have permission to pin this post") + + return can + @classmethod def can_forecast(cls, permission: Self, raise_exception=False): can = permission in ( diff --git a/tests/unit/test_posts/test_services/test_feed.py b/tests/unit/test_posts/test_services/test_feed.py index cad368dbcf..eaf71258a7 100644 --- a/tests/unit/test_posts/test_services/test_feed.py +++ b/tests/unit/test_posts/test_services/test_feed.py @@ -4,9 +4,11 @@ from posts.models import PostUserSnapshot, Post from posts.services.feed import get_posts_feed +from projects.models import Project from questions.models import Question from tests.unit.test_comments.factories import factory_comment from tests.unit.test_posts.factories import factory_post +from tests.unit.test_projects.factories import factory_project from tests.unit.test_questions.factories import create_question, factory_forecast from tests.unit.utils import datetime_aware @@ -118,3 +120,75 @@ def test_get_posts_feed__exclude_unpublished(user1): posts = get_posts_feed(statuses=[Post.CurationStatus.PENDING]) assert len(posts) == 1 assert posts[0].id == post_pending.id + + +def test_get_posts_feed__pinned_posts_on_top_of_project_feed(user1): + tournament = factory_project(type=Project.ProjectTypes.TOURNAMENT) + + # A regular (unpinned) post that ranks higher on hotness + hot_post = factory_post( + author=user1, + default_project=tournament, + question=create_question(question_type=Question.QuestionType.BINARY), + hotness=100, + ) + # A pinned post that ranks lower on hotness + pinned_post = factory_post( + author=user1, + default_project=tournament, + question=create_question(question_type=Question.QuestionType.BINARY), + hotness=1, + is_pinned=True, + ) + + # Within the tournament feed the pinned post is on top despite lower hotness + posts = get_posts_feed(tournaments=[tournament], order_by="-hotness") + assert [p.id for p in posts] == [pinned_post.id, hot_post.id] + + +def test_get_posts_feed__pinned_only_affects_default_project_feed(user1): + tournament = factory_project(type=Project.ProjectTypes.TOURNAMENT) + other_tournament = factory_project(type=Project.ProjectTypes.TOURNAMENT) + + hot_post = factory_post( + author=user1, + default_project=other_tournament, + projects=[tournament], + question=create_question(question_type=Question.QuestionType.BINARY), + hotness=100, + ) + # Pinned in its own default project, but reposted into `tournament` + pinned_post = factory_post( + author=user1, + default_project=other_tournament, + projects=[tournament], + question=create_question(question_type=Question.QuestionType.BINARY), + hotness=1, + is_pinned=True, + ) + + # The pin must NOT apply in a feed the post is only reposted to + posts = get_posts_feed(tournaments=[tournament], order_by="-hotness") + assert [p.id for p in posts] == [hot_post.id, pinned_post.id] + + +def test_get_posts_feed__pinned_ignored_on_unscoped_feed(user1): + tournament = factory_project(type=Project.ProjectTypes.TOURNAMENT) + + hot_post = factory_post( + author=user1, + default_project=tournament, + question=create_question(question_type=Question.QuestionType.BINARY), + hotness=100, + ) + pinned_post = factory_post( + author=user1, + default_project=tournament, + question=create_question(question_type=Question.QuestionType.BINARY), + hotness=1, + is_pinned=True, + ) + + # Without a single-project scope, pin ordering is not applied + posts = get_posts_feed(order_by="-hotness") + assert [p.id for p in posts] == [hot_post.id, pinned_post.id] diff --git a/tests/unit/test_posts/test_views.py b/tests/unit/test_posts/test_views.py index 02d8e1db95..ae8ffc421a 100644 --- a/tests/unit/test_posts/test_views.py +++ b/tests/unit/test_posts/test_views.py @@ -593,6 +593,40 @@ def test_repost(user1, user1_client, user2, user2_client, question_binary): assert target_tournament in post.projects.all() +def test_post_toggle_pin(user1, user1_client, user2, user2_client, question_binary): + tournament = factory_project( + type=Project.ProjectTypes.TOURNAMENT, + override_permissions={user1.pk: ObjectPermission.ADMIN}, + ) + post = factory_post( + author=user2, + default_project=tournament, + question=question_binary, + ) + + url = reverse("post-toggle-pin", kwargs={"pk": post.pk}) + + # Non-admin can't pin + response = user2_client.post(url, {"pin": True}, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + post.refresh_from_db() + assert post.is_pinned is False + + # Admin pins the post + response = user1_client.post(url, {"pin": True}, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.json()["is_pinned"] is True + post.refresh_from_db() + assert post.is_pinned is True + + # Admin unpins the post + response = user1_client.post(url, {"pin": False}, format="json") + assert response.status_code == status.HTTP_200_OK + assert response.json()["is_pinned"] is False + post.refresh_from_db() + assert post.is_pinned is False + + def test_post_vote(user1, user1_client, user2_client, post_binary_public): url = reverse("post-vote", kwargs={"pk": post_binary_public.pk}) From db0ef64b4efe13fa38ebfea7345cfbb8258f43f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 00:44:23 +0000 Subject: [PATCH 2/2] Merge origin/main into claude/github-issue-3223-kip300 Renumber the is_pinned migration 0033 -> 0034 to resolve a leaf-node collision with main's 0033_postusersnapshot_posts_postuser_forecasted_idx, and re-point its dependency at that migration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4pYzdkyDaxzUry6iQP684 --- .../{0033_post_is_pinned.py => 0034_post_is_pinned.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename posts/migrations/{0033_post_is_pinned.py => 0034_post_is_pinned.py} (82%) diff --git a/posts/migrations/0033_post_is_pinned.py b/posts/migrations/0034_post_is_pinned.py similarity index 82% rename from posts/migrations/0033_post_is_pinned.py rename to posts/migrations/0034_post_is_pinned.py index ff8d480d47..99554a6d21 100644 --- a/posts/migrations/0033_post_is_pinned.py +++ b/posts/migrations/0034_post_is_pinned.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("posts", "0032_post_news_hotness"), + ("posts", "0033_postusersnapshot_posts_postuser_forecasted_idx"), ] operations = [