Skip to content

Commit dd1dd83

Browse files
palcuclaude
andcommitted
Skip CP change emails for users who have withdrawn all forecasts
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 <[email protected]>
1 parent 42b7b34 commit dd1dd83

File tree

2 files changed

+99
-0
lines changed

2 files changed

+99
-0
lines changed

posts/services/subscriptions.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,25 @@ def get_last_user_forecasts_for_questions(
137137
return forecasts_map
138138

139139

140+
def get_users_with_active_forecasts_for_questions(
141+
question_ids: Iterable[int],
142+
) -> set[int]:
143+
"""
144+
Returns set of user IDs who have at least one active forecast
145+
on any of the given questions.
146+
147+
An active forecast has end_time IS NULL or end_time > now().
148+
"""
149+
return set(
150+
Forecast.objects.filter(
151+
question_id__in=question_ids,
152+
)
153+
.filter(Q(end_time__isnull=True) | Q(end_time__gt=timezone.now()))
154+
.values_list("author_id", flat=True)
155+
.distinct()
156+
)
157+
158+
140159
def notify_post_cp_change(post: Post):
141160
"""
142161
TODO: write description and check over
@@ -167,7 +186,16 @@ def notify_post_cp_change(post: Post):
167186
[q.pk for q in questions]
168187
)
169188

189+
# Get users who still have active forecasts on any question
190+
users_with_active_forecasts = get_users_with_active_forecasts_for_questions(
191+
[q.pk for q in questions]
192+
)
193+
170194
for subscription in subscriptions:
195+
# Skip users who have withdrawn from all questions in the post
196+
if subscription.user_id not in users_with_active_forecasts:
197+
continue
198+
171199
last_sent = subscription.last_sent_at
172200
max_sorting_diff = None
173201
question_data: list[CPChangeData] = []

tests/unit/test_posts/test_services/test_subscriptions.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import datetime, timedelta
22

3+
from django.utils import timezone
34
from django.utils.timezone import make_aware
45
from freezegun import freeze_time
56

@@ -9,9 +10,12 @@
910
notify_new_comments,
1011
create_subscription_specific_time,
1112
notify_date,
13+
get_users_with_active_forecasts_for_questions,
1214
)
1315
from tests.unit.test_comments.factories import factory_comment
1416
from tests.unit.test_posts.factories import factory_post
17+
from tests.unit.test_questions.factories import create_question, factory_forecast
18+
from questions.models import Question
1519

1620

1721
def test_notify_new_comments(user1, user2):
@@ -124,3 +128,70 @@ def test_notify_date__daily(self, user1):
124128
with freeze_time("2024-09-18T13:46Z"):
125129
notify_date()
126130
assert Notification.objects.filter(recipient=user1).count() == 2
131+
132+
133+
class TestGetUsersWithActiveForecasts:
134+
def test_returns_users_with_active_forecasts(self, user1, user2):
135+
"""Users with active forecasts (end_time=None) should be returned"""
136+
question = create_question(question_type=Question.QuestionType.BINARY)
137+
factory_post(author=user1, question=question)
138+
139+
# User1 has an active forecast (end_time=None)
140+
factory_forecast(author=user1, question=question)
141+
142+
result = get_users_with_active_forecasts_for_questions([question.pk])
143+
144+
assert user1.pk in result
145+
assert user2.pk not in result
146+
147+
def test_excludes_users_with_withdrawn_forecasts(self, user1):
148+
"""Users with withdrawn forecasts (end_time set in past) should be excluded"""
149+
question = create_question(question_type=Question.QuestionType.BINARY)
150+
factory_post(author=user1, question=question)
151+
152+
# User1 has a withdrawn forecast (end_time in the past)
153+
factory_forecast(
154+
author=user1,
155+
question=question,
156+
end_time=timezone.now() - timedelta(hours=1),
157+
)
158+
159+
result = get_users_with_active_forecasts_for_questions([question.pk])
160+
161+
assert user1.pk not in result
162+
163+
def test_includes_users_with_future_end_time(self, user1):
164+
"""Users with end_time in the future are still considered active"""
165+
question = create_question(question_type=Question.QuestionType.BINARY)
166+
factory_post(author=user1, question=question)
167+
168+
# User1 has a forecast that will be withdrawn in the future
169+
factory_forecast(
170+
author=user1,
171+
question=question,
172+
end_time=timezone.now() + timedelta(hours=1),
173+
)
174+
175+
result = get_users_with_active_forecasts_for_questions([question.pk])
176+
177+
assert user1.pk in result
178+
179+
def test_user_with_one_active_one_withdrawn(self, user1):
180+
"""User with at least one active forecast should be included"""
181+
question1 = create_question(question_type=Question.QuestionType.BINARY)
182+
question2 = create_question(question_type=Question.QuestionType.BINARY)
183+
factory_post(author=user1, question=question1)
184+
185+
# User1 has one withdrawn and one active forecast
186+
factory_forecast(
187+
author=user1,
188+
question=question1,
189+
end_time=timezone.now() - timedelta(hours=1),
190+
)
191+
factory_forecast(author=user1, question=question2)
192+
193+
result = get_users_with_active_forecasts_for_questions(
194+
[question1.pk, question2.pk]
195+
)
196+
197+
assert user1.pk in result

0 commit comments

Comments
 (0)