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
24 changes: 23 additions & 1 deletion posts/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,33 @@ def job_compute_movement():
@dramatiq.actor
def job_check_post_open_event():
"""
A cron job to check for newly opened questions and published posts.
A cron job to check for newly published / opened questions.

Fires two distinct events per question, each idempotent:
- publish event (tournament / project follower notifications) when the
parent post's `published_at` passes — i.e. the question becomes Upcoming.
- open event (post-level status change notifications) when `open_time` passes.

We moved this logic from Post-level to Question-level notifications
to enable status update emails for subquestion from groups.
"""

# Tournament / project follower notifications fire at publish time so
# pre-prediction questions are surfaced before they open for forecasting.
publish_questions_qs = Question.objects.filter(
post__in=Post.objects.filter_published(),
published_at_triggered=False,
).select_related("post")

for question in publish_questions_qs:
try:
notify_project_subscriptions_post_open(question.post, question=question)
except Exception:
logger.exception("Failed to handle question publish")
finally:
question.published_at_triggered = True
question.save(update_fields=["published_at_triggered"])

questions_qs = Question.objects.filter(
post__in=Post.objects.filter_published(),
open_time__lte=timezone.now(),
Expand Down
7 changes: 3 additions & 4 deletions projects/services/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from posts.models import Post, PostSubscription, Notebook
from projects.models import Project, ProjectSubscription
from projects.permissions import ObjectPermission
from questions.constants import QuestionStatus
from questions.models import Question
from users.models import User

Expand Down Expand Up @@ -209,9 +208,9 @@ def notify_post_added_to_project(post: Post, project: Project):
return

for question in post.questions.all():
# Dont send a notification if `open_time_triggered` is False
# it will be handled automatically by `handle_question_open`
if question.open_time_triggered and question.status == QuestionStatus.OPEN:
# Don't send a notification if the publish event hasn't fired yet —
# the cron job will pick it up and notify all project subscribers.
if question.published_at_triggered:
notify_project_subscriptions_post_open(
post, question=question, project=project
)
Expand Down
28 changes: 28 additions & 0 deletions questions/migrations/0038_question_published_at_triggered.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.db import migrations, models
from django.utils import timezone


def set_already_published(apps, schema_editor):
# Mark questions on already-published posts as triggered so we don't
# back-fire tournament notifications for the full historical archive.
Question = apps.get_model("questions", "Question")
Question.objects.filter(
post__published_at__lte=timezone.now(),
post__curation_status="approved",
).update(published_at_triggered=True)


class Migration(migrations.Migration):
dependencies = [
("questions", "0037_question_options_order"),
("posts", "0029_remove_notebook_markdown_summary_and_more"),
]

operations = [
migrations.AddField(
model_name="question",
name="published_at_triggered",
field=models.BooleanField(db_index=True, default=False, editable=False),
),
migrations.RunPython(set_already_published, migrations.RunPython.noop),
]
7 changes: 7 additions & 0 deletions questions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ class MultipleChoiceOptionsOrder(models.TextChoices):
default=False, db_index=True, editable=False
)

# Indicates whether we triggered the "post published" event for tournament
# / project follower notifications. Fires once when the parent Post's
# `published_at` passes (question is set to Upcoming).
published_at_triggered = models.BooleanField(
default=False, db_index=True, editable=False
)

# Indicates whether we triggered "handle_cp_revealed" event
# And guarantees idempotency of "on cp revealed" events
cp_reveal_time_triggered = models.BooleanField(
Expand Down
7 changes: 2 additions & 5 deletions questions/services/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from posts.models import Post
from posts.services.subscriptions import notify_post_status_change
from projects.services.cache import invalidate_projects_questions_count_cache
from projects.services.subscriptions import notify_project_subscriptions_post_open
from questions.constants import UnsuccessfulResolutionType
from questions.models import Question, Conditional, UserForecastNotification
from scoring.constants import ScoreTypes
Expand All @@ -28,12 +27,10 @@ def handle_question_open(question: Question):

post = question.get_post()

# Handle post subscriptions
# Handle post subscriptions. Tournament / project follower notifications
# fire earlier (at publish time) and are handled by the cron job.
notify_post_status_change(post, Post.PostStatusChange.OPEN, question=question)

# Handle question on followed projects subscriptions
notify_project_subscriptions_post_open(post, question=question)


@transaction.atomic()
def handle_cp_revealed(question: Question):
Expand Down
118 changes: 118 additions & 0 deletions tests/unit/test_posts/test_jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from datetime import timedelta

from django.utils import timezone

from notifications.models import Notification
from posts.jobs import job_check_post_open_event
from posts.models import Post
from projects.permissions import ObjectPermission
from questions.models import Question
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


def test_job_check_post_open_event__fires_publish_notification_before_open_time(
user1, user2
):
"""
A user following a tournament should be notified at publish time
(i.e., when the question is set to Upcoming) — before `open_time` passes.
"""

project = factory_project(
default_permission=ObjectPermission.FORECASTER, subscribers=[user2]
)
future_open = timezone.now() + timedelta(days=7)

post = factory_post(
author=user1,
default_project=project,
curation_status=Post.CurationStatus.APPROVED,
published_at=timezone.now() - timedelta(minutes=5),
question=create_question(
question_type=Question.QuestionType.BINARY,
open_time=future_open,
scheduled_close_time=future_open + timedelta(days=30),
scheduled_resolve_time=future_open + timedelta(days=60),
),
)

job_check_post_open_event()

post.question.refresh_from_db()
assert post.question.published_at_triggered is True
# Question is still Upcoming — open_time has not passed
assert post.question.open_time_triggered is False

notifications = Notification.objects.filter(
recipient=user2, type="post_status_change"
)
assert notifications.count() == 1
assert notifications.first().params["event"] == "open"


def test_job_check_post_open_event__publish_notification_is_idempotent(user1, user2):
project = factory_project(
default_permission=ObjectPermission.FORECASTER, subscribers=[user2]
)
future_open = timezone.now() + timedelta(days=7)

factory_post(
author=user1,
default_project=project,
curation_status=Post.CurationStatus.APPROVED,
published_at=timezone.now() - timedelta(minutes=5),
question=create_question(
question_type=Question.QuestionType.BINARY,
open_time=future_open,
scheduled_close_time=future_open + timedelta(days=30),
scheduled_resolve_time=future_open + timedelta(days=60),
),
)

job_check_post_open_event()
job_check_post_open_event()

notifications = Notification.objects.filter(
recipient=user2, type="post_status_change"
)
assert notifications.count() == 1


def test_job_check_post_open_event__no_double_publish_on_open(user1, user2):
"""
When a post's open_time also passes, the publish event must not fire a
second tournament notification for the same question.
"""

project = factory_project(
default_permission=ObjectPermission.FORECASTER, subscribers=[user2]
)

post = factory_post(
author=user1,
default_project=project,
curation_status=Post.CurationStatus.APPROVED,
published_at=timezone.now() - timedelta(days=2),
question=create_question(
question_type=Question.QuestionType.BINARY,
open_time=timezone.now() - timedelta(minutes=5),
scheduled_close_time=timezone.now() + timedelta(days=30),
scheduled_resolve_time=timezone.now() + timedelta(days=60),
),
)

job_check_post_open_event()

post.question.refresh_from_db()
assert post.question.published_at_triggered is True
assert post.question.open_time_triggered is True

# One tournament notification (from publish event) + one post status
# change notification (from open event). Tournament followers should not
# be notified twice.
notifications = Notification.objects.filter(
recipient=user2, type="post_status_change"
)
assert notifications.count() == 1
Loading