From 108e6168d8a890d2c1690c1e93d17126ad7f6fab Mon Sep 17 00:00:00 2001 From: Alex Palcuie Date: Wed, 24 Dec 2025 15:30:42 +0200 Subject: [PATCH] Skip CP change emails for users who have withdrawn all forecasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sending "Significant change" (CP change) emails, skip users who have withdrawn their predictions from ALL questions in a post. - Add helper function get_users_with_active_forecasts_for_questions() that returns user IDs with at least one active forecast - Modify notify_post_cp_change() to skip users without active forecasts - Add tests for the new functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- posts/services/subscriptions.py | 28 +++++++ .../test_services/test_subscriptions.py | 74 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/posts/services/subscriptions.py b/posts/services/subscriptions.py index c90b781c3..ca7cbe709 100644 --- a/posts/services/subscriptions.py +++ b/posts/services/subscriptions.py @@ -137,6 +137,25 @@ def get_last_user_forecasts_for_questions( return forecasts_map +def get_users_with_active_forecasts_for_questions( + question_ids: Iterable[int], +) -> set[int]: + """ + Returns set of user IDs who have at least one active forecast + on any of the given questions. + + An active forecast has end_time IS NULL or end_time > now(). + """ + return set( + Forecast.objects.filter( + question_id__in=question_ids, + ) + .filter(Q(end_time__isnull=True) | Q(end_time__gt=timezone.now())) + .values_list("author_id", flat=True) + .distinct() + ) + + def notify_post_cp_change(post: Post): """ TODO: write description and check over @@ -167,7 +186,16 @@ def notify_post_cp_change(post: Post): [q.pk for q in questions] ) + # Get users who still have active forecasts on any question + users_with_active_forecasts = get_users_with_active_forecasts_for_questions( + [q.pk for q in questions] + ) + for subscription in subscriptions: + # Skip users who have withdrawn from all questions in the post + if subscription.user_id not in users_with_active_forecasts: + continue + last_sent = subscription.last_sent_at max_sorting_diff = None question_data: list[CPChangeData] = [] diff --git a/tests/unit/test_posts/test_services/test_subscriptions.py b/tests/unit/test_posts/test_services/test_subscriptions.py index 9e2eccb58..e88a8f8ab 100644 --- a/tests/unit/test_posts/test_services/test_subscriptions.py +++ b/tests/unit/test_posts/test_services/test_subscriptions.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta +from django.utils import timezone from django.utils.timezone import make_aware from freezegun import freeze_time @@ -9,9 +10,12 @@ notify_new_comments, create_subscription_specific_time, notify_date, + get_users_with_active_forecasts_for_questions, ) from tests.unit.test_comments.factories import factory_comment from tests.unit.test_posts.factories import factory_post +from tests.unit.test_questions.factories import create_question, factory_forecast +from questions.models import Question def test_notify_new_comments(user1, user2): @@ -124,3 +128,73 @@ def test_notify_date__daily(self, user1): with freeze_time("2024-09-18T13:46Z"): notify_date() assert Notification.objects.filter(recipient=user1).count() == 2 + + +class TestGetUsersWithActiveForecasts: + def test_returns_users_with_active_forecasts(self, user1, user2): + """Users with active forecasts (end_time=None) should be returned""" + question = create_question(question_type=Question.QuestionType.BINARY) + factory_post(author=user1, question=question) + + # User1 has an active forecast (end_time=None) + factory_forecast(author=user1, question=question) + + result = get_users_with_active_forecasts_for_questions([question.pk]) + + assert user1.pk in result + assert user2.pk not in result + + def test_excludes_users_with_withdrawn_forecasts(self, user1): + """Users with withdrawn forecasts (end_time set in past) should be excluded""" + question = create_question(question_type=Question.QuestionType.BINARY) + factory_post(author=user1, question=question) + + # User1 has a withdrawn forecast (end_time in the past) + factory_forecast( + author=user1, + question=question, + start_time=timezone.now() - timedelta(hours=2), + end_time=timezone.now() - timedelta(hours=1), + ) + + result = get_users_with_active_forecasts_for_questions([question.pk]) + + assert user1.pk not in result + + def test_includes_users_with_future_end_time(self, user1): + """Users with end_time in the future are still considered active""" + question = create_question(question_type=Question.QuestionType.BINARY) + factory_post(author=user1, question=question) + + # User1 has a forecast that will be withdrawn in the future + factory_forecast( + author=user1, + question=question, + end_time=timezone.now() + timedelta(hours=1), + ) + + result = get_users_with_active_forecasts_for_questions([question.pk]) + + assert user1.pk in result + + def test_user_with_one_active_one_withdrawn(self, user1): + """User with at least one active forecast should be included""" + question1 = create_question(question_type=Question.QuestionType.BINARY) + question2 = create_question(question_type=Question.QuestionType.BINARY) + factory_post(author=user1, question=question1) + factory_post(author=user1, question=question2) + + # User1 has one withdrawn and one active forecast + factory_forecast( + author=user1, + question=question1, + start_time=timezone.now() - timedelta(hours=2), + end_time=timezone.now() - timedelta(hours=1), + ) + factory_forecast(author=user1, question=question2) + + result = get_users_with_active_forecasts_for_questions( + [question1.pk, question2.pk] + ) + + assert user1.pk in result