diff --git a/notifications/templates/emails/multiple_choice_option_addition.mjml b/notifications/templates/emails/multiple_choice_option_addition.mjml new file mode 100644 index 000000000..87134c07d --- /dev/null +++ b/notifications/templates/emails/multiple_choice_option_addition.mjml @@ -0,0 +1,109 @@ + + + + + + + + + + Hello {{recipient.username}}, + + + + + + + + + + {% if params.from_projects %} + + + + {% blocktrans %} + These questions have changed status: + {% endblocktrans %} + + + {% for item in params.from_projects %} + {% if item.notifications %} + + + + + + {% blocktrans with project_name=item.project.name %} + In {{project_name}}: + {% endblocktrans %} + + + + {% for notification in item.notifications %} + + + + + {{ notification.question.title|default:notification.post.post_title }} + + {% if notification.post.post_type != 'notebook' %} + + is now {{ notification.event }} + + {% endif %} + + + + {% endfor %} + + + + {% endif %} + {% endfor %} + {% endif %} + + + {% if params.from_posts %} + + + {% if params.from_projects %} + + {% blocktrans %} + In the rest of Metaculus: + {% endblocktrans %} + + {% endif %} + + + {% blocktrans with count=params.from_posts|length %} + These {{count}} questions have changed status: + {% endblocktrans %} + + + + {% for item in params.from_posts %} + + + + + {{ item.question.title|default:item.post.post_title }} + + + is now {{ item.event }} + + + + + {% endfor %} + + {% endif %} + + + + + + + + diff --git a/notifications/templates/emails/multiple_choice_option_deletion.mjml b/notifications/templates/emails/multiple_choice_option_deletion.mjml new file mode 100644 index 000000000..87134c07d --- /dev/null +++ b/notifications/templates/emails/multiple_choice_option_deletion.mjml @@ -0,0 +1,109 @@ + + + + + + + + + + Hello {{recipient.username}}, + + + + + + + + + + {% if params.from_projects %} + + + + {% blocktrans %} + These questions have changed status: + {% endblocktrans %} + + + {% for item in params.from_projects %} + {% if item.notifications %} + + + + + + {% blocktrans with project_name=item.project.name %} + In {{project_name}}: + {% endblocktrans %} + + + + {% for notification in item.notifications %} + + + + + {{ notification.question.title|default:notification.post.post_title }} + + {% if notification.post.post_type != 'notebook' %} + + is now {{ notification.event }} + + {% endif %} + + + + {% endfor %} + + + + {% endif %} + {% endfor %} + {% endif %} + + + {% if params.from_posts %} + + + {% if params.from_projects %} + + {% blocktrans %} + In the rest of Metaculus: + {% endblocktrans %} + + {% endif %} + + + {% blocktrans with count=params.from_posts|length %} + These {{count}} questions have changed status: + {% endblocktrans %} + + + + {% for item in params.from_posts %} + + + + + {{ item.question.title|default:item.post.post_title }} + + + is now {{ item.event }} + + + + + {% endfor %} + + {% endif %} + + + + + + + + diff --git a/questions/services/multiple_choice_handlers.py b/questions/services/multiple_choice_handlers.py index 0a0a38114..b36050a4e 100644 --- a/questions/services/multiple_choice_handlers.py +++ b/questions/services/multiple_choice_handlers.py @@ -209,6 +209,15 @@ def multiple_choice_delete_options( build_question_forecasts(question) + # notify users that about the change + from questions.tasks import multiple_choice_delete_option_notificiations + + multiple_choice_delete_option_notificiations( + question_id=question.id, + timestep=timestep, + comment_author_id=4, # placeholder id + ) + return question @@ -272,4 +281,14 @@ def multiple_choice_add_options( build_question_forecasts(question) + # notify users that about the change + from questions.tasks import multiple_choice_add_option_notificiations + + multiple_choice_add_option_notificiations( + question_id=question.id, + grace_period_end=grace_period_end, + timestep=timestep, + comment_author_id=4, # placeholder id + ) + return question diff --git a/questions/tasks.py b/questions/tasks.py index cad2dcb76..f1969417d 100644 --- a/questions/tasks.py +++ b/questions/tasks.py @@ -1,10 +1,11 @@ import logging -from datetime import timedelta +from datetime import datetime, timedelta import dramatiq from django.db.models import Q from django.utils import timezone +from comments.services.common import create_comment from notifications.constants import MailingTags from notifications.services import ( NotificationPredictedQuestionResolved, @@ -15,14 +16,15 @@ ) from posts.models import Post from posts.services.subscriptions import notify_post_status_change +from questions.models import Forecast, Question, UserForecastNotification from scoring.constants import ScoreTypes from scoring.utils import score_question from users.models import User from utils.dramatiq import concurrency_retries, task_concurrent_limit from utils.frontend import build_frontend_account_settings_url, build_post_url -from .models import Question, UserForecastNotification -from .services.common import get_outbound_question_links -from .services.forecasts import ( +from questions.models import Question, UserForecastNotification +from questions.services.common import get_outbound_question_links +from questions.services.forecasts import ( build_question_forecasts, get_forecasts_per_user, ) @@ -255,3 +257,147 @@ def format_time_remaining(time_remaining: timedelta): return f"{minutes} minute{'s' if minutes != 1 else ''}" else: return f"{total_seconds} second{'s' if total_seconds != 1 else ''}" + + +# @dramatiq.actor +def multiple_choice_delete_option_notificiations( + question_id: int, + timestep: datetime, + comment_author_id: int, +): + question = Question.objects.get(id=question_id) + post = question.get_post() + options_history = question.options_history + removed_options = list(set(options_history[-2][1]) - set(options_history[-1][1])) + + # send out a comment + comment_author = User.objects.get(id=comment_author_id) + create_comment( + comment_author, + post, + text=( + f"PLACEHOLDER: (at)forecasters Option(s) {removed_options} " + f"were removed at {timestep}." + ), + ) + + # # send out an immediate email + # forecaster_emails = ( + # User.objects.filter( + # forecast__in=question.user_forecasts.filter( + # Q(end_time__isnull=True) | Q(end_time__gt=timestep) + # ) + # ) + # .exclude( + # unsubscribed_mailing_tags__contains=[ + # MailingTags.BEFORE_PREDICTION_AUTO_WITHDRAWAL # seems most reasonable + # ] + # ) + # .exclude(email__isnull=True) + # .exclude(email="") + # .values_list("email", flat=True) + # .distinct("id") + # .order_by("id") + # ) + # start = 0 + # batch_size = 300 + # while True: + # emails_batch = list(forecaster_emails[start : start + batch_size]) + # if not emails_batch: + # break + + # send_email_with_template( + # to=emails_batch, + # subject="Multiple Choice Question Options Change", + # template_name="emails/multiple_choice_option_deletion.html", + # context={}, + # use_async=False, + # from_email=settings.EMAIL_NOTIFICATIONS_USER, + # ) + # start += batch_size + + +# @dramatiq.actor +def multiple_choice_add_option_notificiations( + question_id: int, + grace_period_end: datetime, + timestep: datetime, + comment_author_id: int, +): + question = Question.objects.get(id=question_id) + post = question.get_post() + options_history = question.options_history + added_options = list(set(options_history[-1][1]) - set(options_history[-2][1])) + + forecasters = ( + User.objects.filter( + forecast__in=question.user_forecasts.filter( + end_time=grace_period_end + ) # all effected forecasts have their end_time set to grace_period_end + ) + .exclude( + unsubscribed_mailing_tags__contains=[ + MailingTags.BEFORE_PREDICTION_AUTO_WITHDRAWAL # seems most reasonable + ] + ) + .exclude(email__isnull=True) + .exclude(email="") + .distinct("id") + .order_by("id") + ) + + # send out a comment + comment_author = User.objects.get(id=comment_author_id) + create_comment( + comment_author, + post, + text=( + f"PLACEHOLDER: (at)forecasters Option(s) {added_options} " + f"were added at {timestep}. " + f"You have until {grace_period_end} to update your forecast to reflect " + "the new options. " + "If you do not, your forecast will be automatically withdrawn " + f"at {grace_period_end}. " + "Please see our faq (link) for details on how this works." + ), + ) + + # # send out an immediate email + # forecaster_emails = forecasters.values_list("email", flat=True) + # start = 0 + # batch_size = 300 + # while True: + # emails_batch = list(forecaster_emails[start : start + batch_size]) + # if not emails_batch: + # break + + # send_email_with_template( + # to=emails_batch, + # subject="Multiple Choice Question Options Change", + # template_name="emails/multiple_choice_option_addition.html", + # context={}, + # use_async=False, + # from_email=settings.EMAIL_NOTIFICATIONS_USER, + # ) + # start += batch_size + + # schedule a followup email for 1 day before grace period + # (if grace period is more than 1 day away) + if grace_period_end - timedelta(days=1) > timestep: + for forecaster in forecasters: + UserForecastNotification.objects.filter( + user=forecaster, question=question + ).delete() # is this necessary? + UserForecastNotification.objects.update_or_create( + user=forecaster, + question=question, + defaults={ + "trigger_time": grace_period_end - timedelta(days=1), + "email_sent": False, + "forecast": Forecast.objects.filter( + question=question, author=forecaster + ) + .order_by("-start_time") + .first(), + }, + )