Skip to content
Merged
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
30 changes: 30 additions & 0 deletions posts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,36 @@ def annotate_user_last_forecasts_date(self, author_id: int):
)
)

def filter_user_has_not_forecasted(self, author_id: int):
"""
Filter to posts where user has NOT forecasted.
Uses NOT EXISTS which is more efficient than annotate + filter IS NULL.
"""
return self.filter(
~Exists(
PostUserSnapshot.objects.filter(
user_id=author_id,
post_id=OuterRef("pk"),
last_forecast_date__isnull=False,
)
)
)

def filter_user_has_forecasted(self, author_id: int):
"""
Filter to posts where user HAS forecasted.
Uses EXISTS which is more efficient than annotate + filter IS NOT NULL.
"""
return self.filter(
Exists(
PostUserSnapshot.objects.filter(
user_id=author_id,
post_id=OuterRef("pk"),
last_forecast_date__isnull=False,
)
)
)

def annotate_has_active_forecast(self, author_id: int):
"""
Annotates if user has active forecast for post
Expand Down
75 changes: 48 additions & 27 deletions posts/services/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,54 +130,76 @@ def get_posts_feed(
qs = qs.filter(forecast_type_q)

statuses = list(statuses or [])

q = Q()
now = timezone.now()
status_q = Q()

for status in statuses:
if status in Post.CurationStatus:
q |= Q(curation_status=status)
status_q |= Q(curation_status=status)
if status == "upcoming":
q |= Q(
status_q |= (
Q(notebook__isnull=True)
& Q(curation_status=Post.CurationStatus.APPROVED)
& Q(open_time__gte=timezone.now())
& Exists(
Question.objects.filter(
post_id=OuterRef("pk"),
open_time__gte=now,
)
)
)
if status == "closed":
q |= Q(notebook__isnull=True) & (
Q(curation_status=Post.CurationStatus.APPROVED)
& (
Q(actual_close_time__isnull=False, resolved=False)
| Q(scheduled_close_time__lte=timezone.now(), resolved=False)
status_q |= (
Q(notebook__isnull=True)
& Q(curation_status=Post.CurationStatus.APPROVED)
& Exists(
Question.objects.filter(
post_id=OuterRef("pk"),
resolution__isnull=True,
).filter(
Q(actual_close_time__isnull=False)
| Q(scheduled_close_time__lte=now)
)
)
)
if status == "pending_resolution":
q |= (
status_q |= (
Q(notebook__isnull=True)
& Q(curation_status=Post.CurationStatus.APPROVED)
& Q(resolved=False, scheduled_resolve_time__lte=timezone.now())
& Exists(
Question.objects.filter(
post_id=OuterRef("pk"),
resolution__isnull=True,
scheduled_resolve_time__lte=now,
)
)
)
if order_by in [None, "-" + PostFilterSerializer.Order.HOTNESS]:
order_by = "-" + PostFilterSerializer.Order.SCHEDULED_RESOLVE_TIME
if status == "resolved":
q |= Q(notebook__isnull=True) & Q(
resolved=True, curation_status=Post.CurationStatus.APPROVED
status_q |= (
Q(notebook__isnull=True)
& Q(curation_status=Post.CurationStatus.APPROVED)
& Exists(
Question.objects.filter(
post_id=OuterRef("pk"),
resolution__isnull=False,
)
)
)
if status == "open":
q |= Q(
Q(published_at__lte=timezone.now())
status_q |= Q(
Q(published_at__lte=now)
& Q(curation_status=Post.CurationStatus.APPROVED)
& (
# Notebooks don't support statuses filter
# So we add fallback condition list this
Q(notebook_id__isnull=False)
| (
Q(open_time__lte=timezone.now())
Q(open_time__lte=now)
& Q(
(
Q(actual_close_time__isnull=True)
| Q(actual_close_time__gte=timezone.now())
| Q(actual_close_time__gte=now)
)
& Q(scheduled_close_time__gte=timezone.now())
& Q(scheduled_close_time__gte=now)
)
& Q(resolved=False)
)
Expand All @@ -186,9 +208,9 @@ def get_posts_feed(

# Include only approved posts if no curation status specified
if not any(status in Post.CurationStatus for status in statuses):
q &= Q(curation_status=Post.CurationStatus.APPROVED)
status_q &= Q(curation_status=Post.CurationStatus.APPROVED)

qs = qs.filter(q)
qs = qs.filter(status_q)

if forecaster_id:
qs = qs.annotate_user_last_forecasts_date(forecaster_id).filter(
Expand All @@ -202,10 +224,9 @@ def get_posts_feed(
qs = qs.annotate_has_active_forecast(forecaster_id).filter(
has_active_forecast=not withdrawn
)

if not_forecaster_id:
qs = qs.annotate_user_last_forecasts_date(not_forecaster_id).filter(
user_last_forecasts_date__isnull=True
)
qs = qs.filter_user_has_not_forecasted(not_forecaster_id)

if upvoted_by:
qs = qs.filter(
Expand Down Expand Up @@ -296,7 +317,7 @@ def get_posts_feed(
)
if order_type == PostFilterSerializer.Order.NEWS_HOTNESS:
if not order_desc:
raise ValidationError("Ascending is not supported for In the news order")
raise ValidationError('Ascending is not supported for "In the news" order')

# Annotate news hotness and exclude notebooks
qs = qs.annotate_news_hotness().filter(notebook__isnull=True)
Expand Down
48 changes: 48 additions & 0 deletions questions/migrations/0034_question_status_filter_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from django.db import migrations, models


class Migration(migrations.Migration):
"""
Add indexes to optimize question-level status filtering in feed queries.

These partial indexes support the Exists() subqueries used in get_posts_feed().
"""

dependencies = [
("questions", "0033_question_post_fk"),
]

operations = [
migrations.AddIndex(
model_name="question",
index=models.Index(
fields=["post"],
name="idx_question_post_unresolved",
condition=models.Q(resolution__isnull=True),
),
),
migrations.AddIndex(
model_name="question",
index=models.Index(
fields=["post"],
name="idx_question_post_resolved",
condition=models.Q(resolution__isnull=False),
),
),
migrations.AddIndex(
model_name="question",
index=models.Index(
fields=["post", "scheduled_close_time"],
name="idx_question_post_close_time",
condition=models.Q(resolution__isnull=True),
),
),
migrations.AddIndex(
model_name="question",
index=models.Index(
fields=["post", "scheduled_resolve_time"],
name="idx_question_post_resolve_time",
condition=models.Q(resolution__isnull=True),
),
),
]
26 changes: 26 additions & 0 deletions questions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,32 @@ def get_forecasters(self) -> QuerySet["User"]:
)
)

class Meta:
indexes = [
# Partial indexes for question-level status filtering in feed queries
# Used by Exists() subqueries in get_posts_feed()
models.Index(
fields=["post"],
name="idx_question_post_unresolved",
condition=Q(resolution__isnull=True),
),
models.Index(
fields=["post"],
name="idx_question_post_resolved",
condition=Q(resolution__isnull=False),
),
models.Index(
fields=["post", "scheduled_close_time"],
name="idx_question_post_close_time",
condition=Q(resolution__isnull=True),
),
models.Index(
fields=["post", "scheduled_resolve_time"],
name="idx_question_post_resolve_time",
condition=Q(resolution__isnull=True),
),
]


QUESTION_CONTINUOUS_TYPES = [
Question.QuestionType.NUMERIC,
Expand Down