Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions front_end/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,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",
Expand Down
6 changes: 6 additions & 0 deletions front_end/src/app/(main)/questions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,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");
Expand Down
25 changes: 24 additions & 1 deletion front_end/src/components/post_actions/post_dropdown_menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -101,6 +104,21 @@ export const PostDropdownMenu: FC<Props> = ({ 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);

Expand Down Expand Up @@ -252,6 +270,11 @@ export const PostDropdownMenu: FC<Props> = ({ post, button, hideShare }) => {
// Include if user is admin
...(isAdmin
? [
{
id: "togglePin",
name: isPinned ? t("unpinFromProject") : t("pinInProject"),
onClick: togglePin,
},
{
id: "deleteQuestion",
name: t("delete"),
Expand Down
11 changes: 10 additions & 1 deletion front_end/src/components/post_card/basic_post_card/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -56,7 +58,7 @@ const BasicPostCard: FC<PropsWithChildren<Props>> = ({
)}
<div
className={cn(
"flex flex-col overflow-hidden rounded bg-gray-0 px-5 py-4 transition-colors @container dark:bg-gray-0-dark",
"relative flex flex-col overflow-hidden rounded bg-gray-0 px-5 py-4 transition-colors @container dark:bg-gray-0-dark",
{ regular: "border", highlighted: "border border-l-4" }[
borderVariant
],
Expand All @@ -67,6 +69,13 @@ const BasicPostCard: FC<PropsWithChildren<Props>> = ({
}[borderColor]
)}
>
{post.is_pinned && (
<FontAwesomeIcon
icon={faThumbtack}
className="absolute right-2 top-2 text-blue-500 dark:text-blue-500-dark"
aria-label="Pinned"
/>
)}
<Link
href={getPostLink(post)}
prefetch={false}
Expand Down
9 changes: 9 additions & 0 deletions front_end/src/services/api/posts/posts.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ class ServerPostsApiClass extends PostsApi {
);
}

async togglePinPost(
postId: number,
pin: boolean
): Promise<PostWithForecasts> {
return this.post<PostWithForecasts>(`/posts/${postId}/toggle-pin/`, {
pin,
});
}

async updateSubscriptions(postId: number, subscriptions: PostSubscription[]) {
return this.post<PostSubscription[], PostSubscription[]>(
`/posts/${postId}/subscriptions/`,
Expand Down
1 change: 1 addition & 0 deletions front_end/src/types/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ type BasePost = {
status: PostStatus;
resolved: boolean;
user_permission: ProjectPermissions;
is_pinned?: boolean;
comment_count?: number;
forecasts_count?: number;
subscriptions?: Array<PostSubscription & { created_at: string }>;
Expand Down
18 changes: 18 additions & 0 deletions posts/migrations/0034_post_is_pinned.py
Original file line number Diff line number Diff line change
@@ -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", "0033_postusersnapshot_posts_postuser_forecasted_idx"),
]

operations = [
migrations.AddField(
model_name="post",
name="is_pinned",
field=models.BooleanField(db_index=True, default=False),
),
]
3 changes: 3 additions & 0 deletions posts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,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, "
Expand Down
1 change: 1 addition & 0 deletions posts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class Meta:
"open_time",
"nr_forecasters",
"html_metadata_json",
"is_pinned",
)

def get_author_username(self, obj: Post):
Expand Down
18 changes: 18 additions & 0 deletions posts/services/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down
69 changes: 68 additions & 1 deletion posts/services/feed.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -22,6 +32,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,
Expand Down Expand Up @@ -342,6 +383,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")
Expand Down
5 changes: 5 additions & 0 deletions posts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
),
path("posts/<int:pk>/", views.post_detail, name="post-detail"),
path("posts/<int:pk>/boost/", views.activity_boost_api_view, name="post-boost"),
path(
"posts/<int:pk>/toggle-pin/",
views.post_toggle_pin_api_view,
name="post-toggle-pin",
),
path("posts/<int:pk>/repost/", views.repost_api_view, name="post-repost"),
path("posts/<int:pk>/approve/", views.post_approve_api_view, name="post-approve"),
path(
Expand Down
29 changes: 29 additions & 0 deletions posts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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):
"""
Expand Down
9 changes: 9 additions & 0 deletions projects/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Loading
Loading