Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
5ba498d
Top comments card design
hlbmtc Mar 12, 2026
0303b96
Small fixes
hlbmtc Mar 12, 2026
3fe16a2
Small fixes
hlbmtc Mar 12, 2026
a3315f3
Small fixes
hlbmtc Mar 12, 2026
0564847
Small fix
hlbmtc Mar 12, 2026
faa50f5
Added PostPreview continuous fit
hlbmtc Mar 13, 2026
13db015
Small tweaks
hlbmtc Mar 13, 2026
bf56a28
Added comment skeleton
hlbmtc Mar 13, 2026
cc06b6e
- Compact version
hlbmtc Mar 13, 2026
7a96210
Moved comment "View" button
hlbmtc Mar 13, 2026
fd00f62
Adjusted button
hlbmtc Mar 13, 2026
244ac47
Small polish
hlbmtc Mar 13, 2026
5c94b34
Don't show "expand" button for short comments
hlbmtc Mar 13, 2026
c0a74b6
Tweaked the skeleton
hlbmtc Mar 13, 2026
fa40ffe
Small fix
hlbmtc Mar 13, 2026
1c1bdc3
Moved comments + forecasters block on top
hlbmtc Mar 13, 2026
cab8cb0
Don't fetch CP history for top comments
hlbmtc Mar 13, 2026
a9de7d3
Added notebooks support
hlbmtc Mar 13, 2026
d244ef6
Mobile improvements
hlbmtc Mar 13, 2026
f1c0883
Adjusted page navigation transition
hlbmtc Mar 13, 2026
b000e72
Skeleton tweaks
hlbmtc Mar 13, 2026
a235faf
Extra fixes
hlbmtc Mar 13, 2026
7e39bf4
Small fix
hlbmtc Mar 13, 2026
f155ce4
Review comments
hlbmtc Mar 16, 2026
b8395fa
Review comments
hlbmtc Mar 16, 2026
584be9f
Small refactor
hlbmtc Mar 16, 2026
189d11f
Merge branch 'main' into feat/top-comments-card-design
hlbmtc Mar 16, 2026
0379494
Small refactor
hlbmtc Mar 16, 2026
3f11fcd
Small fix
hlbmtc Mar 16, 2026
bcf5452
Small fix
hlbmtc Mar 16, 2026
53af71c
PR review fixes
hlbmtc Mar 16, 2026
bce39ea
Small fix
hlbmtc Mar 16, 2026
3cc1e55
Backend integration
hlbmtc Mar 17, 2026
873a706
Comments feed frontend
hlbmtc Mar 17, 2026
064404f
Adjusted migration
hlbmtc Mar 17, 2026
f60c415
Small fix
hlbmtc Mar 17, 2026
6619430
Small fix
hlbmtc Mar 17, 2026
e5abf52
Small fix
hlbmtc Mar 17, 2026
d2bc11f
Small fix
hlbmtc Mar 17, 2026
f67afb5
Merge remote-tracking branch 'origin/feat/4497-simple-comments-feed' …
hlbmtc Mar 17, 2026
b35cfab
Small fix
hlbmtc Mar 17, 2026
a46bdfe
Small fix
hlbmtc Mar 17, 2026
4a2a7fb
Merge branch 'main' into feat/4497-simple-comments-feed
hlbmtc Mar 17, 2026
ba560a2
Small fix
hlbmtc Mar 17, 2026
d491582
Ajdusted loading
hlbmtc Mar 17, 2026
3af6dff
Migration fix
hlbmtc Mar 17, 2026
df53b65
Merge remote-tracking branch 'origin/feat/4497-simple-comments-feed' …
hlbmtc Mar 17, 2026
4b8e71c
Added Voting
hlbmtc Mar 17, 2026
6ff1def
Adjusted mobile nav
hlbmtc Mar 17, 2026
713d522
Small refactor
hlbmtc Mar 17, 2026
dd672e7
Added exclude_bots flag
hlbmtc Mar 17, 2026
7398d63
Small adjustments
hlbmtc Mar 17, 2026
e78374a
Merge remote-tracking branch 'origin/feat/4497-simple-comments-feed' …
hlbmtc Mar 17, 2026
07bc3cb
Comments denormalization
hlbmtc Mar 17, 2026
4bc4ef0
Small fix
hlbmtc Mar 17, 2026
f41943e
Merge branch 'feat/comment-denormalized-fields' into feat/4497-simple…
hlbmtc Mar 17, 2026
4000898
Small fix
hlbmtc Mar 17, 2026
5742872
Small fix
hlbmtc Mar 17, 2026
ba12edf
Merge branch 'feat/comment-denormalized-fields' into feat/4497-simple…
hlbmtc Mar 17, 2026
5e4481e
Small fix
hlbmtc Mar 17, 2026
194b096
Merge branch 'main' into feat/4497-simple-comments-feed
hlbmtc Mar 17, 2026
40bfe27
Small adjustments
hlbmtc Mar 17, 2026
3965cec
Backend fixes
hlbmtc Mar 17, 2026
b71a664
Small fix
hlbmtc Mar 18, 2026
f4fda79
Comments feed: use CountlessLimitOffsetPagination isntead of LimitOff…
hlbmtc Mar 18, 2026
905eead
Small fix
hlbmtc Mar 18, 2026
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
7 changes: 7 additions & 0 deletions comments/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@
class CommentReportType(models.TextChoices):
SPAM = "spam"
VIOLATION = "violation"


class TimeWindow(models.TextChoices):
ALL_TIME = "all_time"
PAST_WEEK = "past_week"
PAST_MONTH = "past_month"
PAST_YEAR = "past_year"
50 changes: 49 additions & 1 deletion comments/migrations/0024_add_denormalized_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,50 @@
import django.contrib.postgres.indexes
import django.contrib.postgres.search
from django.conf import settings
from django.db import migrations, models
from django.db import connection, migrations, models


def backfill_vote_score(apps, schema_editor):
with connection.cursor() as cursor:
Comment on lines +6 to +10
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify migration callback DB access pattern.
rg -n "from django.db import connection|connection.cursor\\(|schema_editor.connection.cursor\\(" comments/migrations/0024_add_denormalized_fields.py

Repository: Metaculus/metaculus

Length of output: 246


🏁 Script executed:

cat -n comments/migrations/0024_add_denormalized_fields.py

Repository: Metaculus/metaculus

Length of output: 4348


Use schema_editor.connection instead of global django.db.connection in migration callbacks.

RunPython should use the migration's active DB alias; global connection can target the wrong DB in multi-database setups. This migration calls three backfill functions via migrations.RunPython() (lines 91–95), all of which use the incorrect pattern on lines 10, 25, and 40.

Suggested fix
-from django.db import connection, migrations, models
+from django.db import migrations, models
@@
 def backfill_vote_score(apps, schema_editor):
-    with connection.cursor() as cursor:
+    with schema_editor.connection.cursor() as cursor:
@@
 def backfill_cmm_count(apps, schema_editor):
-    with connection.cursor() as cursor:
+    with schema_editor.connection.cursor() as cursor:
@@
 def backfill_text_original_search_vector(apps, schema_editor):
-    with connection.cursor() as cursor:
+    with schema_editor.connection.cursor() as cursor:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@comments/migrations/0024_add_denormalized_fields.py` around lines 6 - 10, The
migration callback backfill functions (e.g. backfill_vote_score and the two
other functions invoked via migrations.RunPython) are using the global
django.db.connection; change them to use the migration's active DB via
schema_editor.connection (use the schema_editor passed into each backfill
function) so all cursor/connection operations become "with
schema_editor.connection.cursor() as cursor:" instead of "with
connection.cursor() as cursor:". Update every backfill function referenced by
the RunPython calls to use schema_editor.connection.

cursor.execute("""
UPDATE comments_comment c
SET vote_score = COALESCE(sub.score, 0)
FROM (
SELECT comment_id, SUM(direction) AS score
FROM comments_commentvote
GROUP BY comment_id
) sub
WHERE c.id = sub.comment_id
""")
print(f"\n vote_score: {cursor.rowcount} rows updated")


def backfill_cmm_count(apps, schema_editor):
with connection.cursor() as cursor:
cursor.execute("""
UPDATE comments_comment c
SET cmm_count = sub.cnt
FROM (
SELECT comment_id, COUNT(*) AS cnt
FROM comments_changedmymindentry
GROUP BY comment_id
) sub
WHERE c.id = sub.comment_id
""")
print(f"\n cmm_count: {cursor.rowcount} rows updated")


def backfill_text_original_search_vector(apps, schema_editor):
with connection.cursor() as cursor:
cursor.execute("""
UPDATE comments_comment
SET text_original_search_vector = to_tsvector('english', COALESCE(text_original, ''))
WHERE text_original IS NOT NULL
AND text_original != ''
AND is_private = false
AND is_soft_deleted = false
""")
print(f"\n text_original_search_vector: {cursor.rowcount} rows updated")


class Migration(migrations.Migration):
Expand Down Expand Up @@ -45,4 +88,9 @@ class Migration(migrations.Migration):
name="comment_text_search_vector_idx",
),
),
migrations.RunPython(backfill_vote_score, migrations.RunPython.noop),
migrations.RunPython(backfill_cmm_count, migrations.RunPython.noop),
migrations.RunPython(
backfill_text_original_search_vector, migrations.RunPython.noop
),
]
12 changes: 2 additions & 10 deletions comments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from django.db.models.functions import Coalesce
from django.db.models.lookups import Exact
from django.utils import timezone
from sql_util.aggregates import SubqueryAggregate

from posts.models import Post
from projects.models import Project
Expand All @@ -28,15 +27,6 @@


class CommentQuerySet(models.QuerySet):
def annotate_vote_score(self):
return self.annotate(
annotated_vote_score=Coalesce(
SubqueryAggregate("comment_votes__direction", aggregate=Sum),
0,
output_field=IntegerField(),
)
)

def annotate_user_vote(self, user: User):
"""
Annotates queryset with the user's vote option
Expand Down Expand Up @@ -180,12 +170,14 @@ def update_vote_score(self):
]
self.vote_score = score
self.save(update_fields=["vote_score"])

return score

def update_cmm_count(self):
count = self.changedmymindentry_set.count()
self.cmm_count = count
self.save(update_fields=["cmm_count"])

return count


Expand Down
23 changes: 21 additions & 2 deletions comments/serializers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from comments.constants import TimeWindow
from comments.models import Comment, KeyFactor, CommentsOfTheWeekEntry
from comments.utils import comments_extract_user_mentions_mapping
from posts.models import Post
Expand All @@ -27,13 +28,33 @@ class CommentFilterSerializer(serializers.Serializer):
is_private = serializers.BooleanField(required=False, allow_null=True)
include_deleted = serializers.BooleanField(required=False, allow_null=True)
last_viewed_at = serializers.DateTimeField(required=False, allow_null=True)
time_window = serializers.ChoiceField(
choices=TimeWindow.choices,
required=False,
allow_null=True,
)
search = serializers.CharField(required=False, allow_null=True, min_length=3)
exclude_bots = serializers.BooleanField(required=False, default=False)
post_status = serializers.ChoiceField(
choices=Post.CurationStatus.choices,
required=False,
allow_null=True,
)

def validate_post(self, value: int):
try:
return Post.objects.get(pk=value)
except Post.DoesNotExist:
raise ValidationError("Post Does not exist")

def validate(self, attrs):
sort = attrs.get("sort")
search = attrs.get("search")
if sort == "relevance" and not search:
raise ValidationError({"sort": "Relevance sort requires a search query."})

return attrs


class CommentSerializer(serializers.ModelSerializer):
author = BaseUserSerializer()
Expand Down Expand Up @@ -196,8 +217,6 @@ def serialize_comment_many(
qs = qs.select_related(
"included_forecast__question", "author", "on_post"
).prefetch_related("key_factors")
qs = qs.annotate_vote_score()

if current_user:
qs = qs.annotate_user_vote(current_user)

Expand Down
36 changes: 35 additions & 1 deletion comments/services/common.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import datetime
import difflib

from django.db import transaction
from django.db import IntegrityError, transaction
from django.db.models import (
F,
Sum,
Expand Down Expand Up @@ -267,6 +267,40 @@ def compute_comment_score(
return score


def vote_comment(comment: Comment, user: User, direction: int | None) -> int:
try:
with transaction.atomic():
CommentVote.objects.filter(user=user, comment=comment).delete()

if direction:
CommentVote.objects.create(
user=user, comment=comment, direction=direction
)
except IntegrityError:
pass

return comment.update_vote_score()


def toggle_cmm(comment: Comment, user: User, enabled: bool) -> bool | None:
"""Returns True if created, False if deleted, None if no-op."""
try:
with transaction.atomic():
cmm = ChangedMyMindEntry.objects.filter(user=user, comment=comment)

if not enabled and cmm.exists():
cmm.delete()
comment.update_cmm_count()
return False

if enabled and not cmm.exists():
ChangedMyMindEntry.objects.create(user=user, comment=comment)
comment.update_cmm_count()
return True
except IntegrityError:
pass


Comment on lines +270 to +303
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not silently swallow IntegrityError in vote/CMM mutation paths.

Current except IntegrityError: pass hides failed writes and makes API outcomes ambiguous (None/stale score without surfacing an error).

Safer pattern: validate inputs + deterministic writes + explicit failure
 def vote_comment(comment: Comment, user: User, direction: int | None) -> int:
-    try:
-        with transaction.atomic():
-            CommentVote.objects.filter(user=user, comment=comment).delete()
-
-            if direction:
-                CommentVote.objects.create(
-                    user=user, comment=comment, direction=direction
-                )
-    except IntegrityError:
-        pass
+    if direction not in (None, -1, 1):
+        raise ValidationError("direction must be one of: -1, 1, null")
+
+    with transaction.atomic():
+        if direction is None:
+            CommentVote.objects.filter(user=user, comment=comment).delete()
+        else:
+            CommentVote.objects.update_or_create(
+                user=user,
+                comment=comment,
+                defaults={"direction": direction},
+            )

     return comment.update_vote_score()


 def toggle_cmm(comment: Comment, user: User, enabled: bool) -> bool | None:
     """Returns True if created, False if deleted, None if no-op."""
-    try:
-        with transaction.atomic():
-            cmm = ChangedMyMindEntry.objects.filter(user=user, comment=comment)
-
-            if not enabled and cmm.exists():
-                cmm.delete()
-                comment.update_cmm_count()
-                return False
-
-            if enabled and not cmm.exists():
-                ChangedMyMindEntry.objects.create(user=user, comment=comment)
-                comment.update_cmm_count()
-                return True
-    except IntegrityError:
-        pass
+    with transaction.atomic():
+        if enabled:
+            _, created = ChangedMyMindEntry.objects.get_or_create(
+                user=user, comment=comment
+            )
+            if created:
+                comment.update_cmm_count()
+                return True
+            return None
+
+        deleted, _ = ChangedMyMindEntry.objects.filter(
+            user=user, comment=comment
+        ).delete()
+        if deleted:
+            comment.update_cmm_count()
+            return False
+        return None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@comments/services/common.py` around lines 270 - 303, Both vote_comment and
toggle_cmm currently swallow IntegrityError which hides failures; update them to
validate inputs up-front (e.g., ensure direction is one of the allowed values
and enabled is a bool), remove the silent "except IntegrityError: pass", and
instead catch IntegrityError as e and either log the full error (using
logger.exception or similar) and re-raise the exception so callers see the
failure, or raise a clear custom exception; also ensure toggle_cmm always
returns an explicit True/False on success (no None) and that any failure
surfaces by raising the error so API behavior is deterministic (refer to
vote_comment, toggle_cmm, CommentVote.objects.create,
ChangedMyMindEntry.objects.create/delete, and
comment.update_vote_score/update_cmm_count to locate the mutation points).

def set_comment_excluded_from_week_top(comment: Comment, excluded: bool = True):
entry = comment.comments_of_the_week_entry
if entry:
Expand Down
56 changes: 44 additions & 12 deletions comments/services/feed.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
from datetime import datetime
from datetime import datetime, timedelta

from django.db.models import Q, Case, When, Value, IntegerField, Exists, OuterRef
from django.contrib.postgres.search import SearchQuery, SearchRank
from django.db.models import F, Q, Case, When, Value, IntegerField, Exists, OuterRef
from django.utils import timezone

from comments.constants import TimeWindow
from comments.models import Comment
from posts.models import Post

TIME_WINDOW_DELTAS = {
TimeWindow.PAST_WEEK: timedelta(days=7),
TimeWindow.PAST_MONTH: timedelta(days=30),
TimeWindow.PAST_YEAR: timedelta(days=365),
}


def get_comments_feed(
Expand All @@ -15,16 +25,19 @@ def get_comments_feed(
sort=None,
is_private=None,
focus_comment_id: int = None,
include_deleted=False,
include_deleted: bool | None = None,
last_viewed_at: datetime = None,
time_window: str = None,
search: str = None,
exclude_bots: bool = False,
post_status: Post.CurationStatus | None = None,
):
user = user if user and user.is_authenticated else None
sort = sort or "-created_at"
order_by_args = []

# Require at least one filter
if not post and not author and not (is_private and user):
return qs.none()
if post_status:
qs = qs.filter(on_post__curation_status=post_status)

if parent_isnull is not None:
qs = qs.filter(parent=None)
Expand Down Expand Up @@ -83,14 +96,34 @@ def get_comments_feed(
else:
qs = qs.filter(is_private=False)

if not include_deleted:
if exclude_bots:
qs = qs.filter(author__is_bot=False)

if include_deleted is None:
qs = qs.filter(
Q(is_soft_deleted=False)
| Exists(
Comment.objects.filter(parent_id=OuterRef("pk"), is_soft_deleted=False)
)
)

if include_deleted is False:
qs = qs.filter(is_soft_deleted=False)

# Time window filter
if time_window and time_window in TIME_WINDOW_DELTAS:
cutoff = timezone.now() - TIME_WINDOW_DELTAS[time_window]
qs = qs.filter(created_at__gte=cutoff)

# Full-text search using stored search vector
if search:
query = SearchQuery(search, search_type="websearch", config="english")
qs = qs.filter(text_original_search_vector=query)
if sort == "relevance":
qs = qs.annotate(
search_rank=SearchRank(F("text_original_search_vector"), query)
)

# Filter comments located under Posts current user is allowed to see
qs = qs.filter_by_user_permission(user=user)

Expand Down Expand Up @@ -124,11 +157,10 @@ def get_comments_feed(
order_by_args.insert(pinned_idx, "-is_focused_comment")

if sort:
if "vote_score" in sort:
qs = qs.annotate_vote_score()
sort = sort.replace("vote_score", "annotated_vote_score")

order_by_args.append(sort)
if sort == "relevance":
order_by_args.append("-search_rank")
else:
order_by_args.append(sort)

if order_by_args:
qs = qs.order_by(*order_by_args)
Expand Down
Loading
Loading