diff --git a/questions/admin.py b/questions/admin.py index d12545aa6d..aa0eac0271 100644 --- a/questions/admin.py +++ b/questions/admin.py @@ -1,10 +1,17 @@ from admin_auto_filters.filters import AutocompleteFilterFactory -from django.contrib import admin +from datetime import datetime, timedelta + +from django import forms +from django.contrib import admin, messages +from django.core.exceptions import PermissionDenied from django.db.models import QuerySet -from django.http import HttpResponse -from django.urls import reverse +from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils import timezone from django.utils.html import format_html from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin +from rest_framework.exceptions import ValidationError as DRFValidationError from posts.models import Post from questions.constants import UnsuccessfulResolutionType @@ -17,10 +24,402 @@ ) from questions.services.forecasts import build_question_forecasts from questions.types import AggregationMethod +from questions.services.multiple_choice_handlers import ( + MultipleChoiceOptionsUpdateSerializer, + get_all_options_from_history, + multiple_choice_add_options, + multiple_choice_change_grace_period_end, + multiple_choice_delete_options, + multiple_choice_rename_option, + multiple_choice_reorder_options, +) from utils.csv_utils import export_all_data_for_questions from utils.models import CustomTranslationAdmin +def get_latest_options_history_datetime(options_history): + if not options_history: + return None + raw_timestamp = options_history[-1][0] + try: + if isinstance(raw_timestamp, datetime): + parsed_timestamp = raw_timestamp + elif isinstance(raw_timestamp, str): + parsed_timestamp = datetime.fromisoformat(raw_timestamp) + else: + return None + except ValueError: + return None + if timezone.is_naive(parsed_timestamp): + parsed_timestamp = timezone.make_aware(parsed_timestamp) + return parsed_timestamp + + +def has_active_grace_period(options_history, reference_time=None): + reference_time = reference_time or timezone.now() + latest_timestamp = get_latest_options_history_datetime(options_history) + return bool(latest_timestamp and latest_timestamp > reference_time) + + +class MultipleChoiceOptionsAdminForm(forms.Form): + ACTION_RENAME = "rename_options" + ACTION_DELETE = "delete_options" + ACTION_ADD = "add_options" + ACTION_CHANGE_GRACE = "change_grace_period_end" # not ready yet + ACTION_REORDER = "reorder_options" + ACTION_CHOICES = ( + (ACTION_RENAME, "Rename options"), + (ACTION_DELETE, "Delete options"), + (ACTION_ADD, "Add options"), + # (ACTION_CHANGE_GRACE, "Change grace period end"), + (ACTION_REORDER, "Reorder options"), + ) + + action = forms.ChoiceField(choices=ACTION_CHOICES, required=True) + old_option = forms.ChoiceField(required=False) + new_option = forms.CharField( + required=False, label="New option text", strip=True, max_length=200 + ) + options_to_delete = forms.MultipleChoiceField( + required=False, widget=forms.CheckboxSelectMultiple + ) + new_options = forms.CharField( + required=False, + help_text="Comma-separated options to add before the catch-all option.", + ) + grace_period_end = forms.DateTimeField( + required=False, + help_text=( + "Default value is 2 weeks from now. " + "Required when adding options; must be in the future. " + "Format: YYYY-MM-DD or YYYY-MM-DD HH:MM (time optional)." + ), + input_formats=["%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"], + ) + delete_comment = forms.CharField( + required=False, + label="Delete options comment", + widget=forms.Textarea(attrs={"rows": 3}), + help_text="Placeholders will auto-fill; edit as needed." + " {removed_options} becomes ['a', 'b'], {timestep} is the time of " + "deletion in isoformat.", + ) + add_comment = forms.CharField( + required=False, + label="Add options comment", + widget=forms.Textarea(attrs={"rows": 4}), + help_text="Placeholders will auto-fill; edit as needed." + " {removed_options} becomes ['a', 'b'], {timestep} is the time of " + "deletion in isoformat.", + ) + + def __init__(self, question: Question, *args, **kwargs): + self.question = question + super().__init__(*args, **kwargs) + + options_history = question.options_history or [] + self.options_grace_period_end = get_latest_options_history_datetime( + options_history + ) + default_delete_comment = ( + "Options {removed_options} were removed at {timestep}. " + "Forecasts were adjusted to keep remaining probability on the catch-all." + ) + default_add_comment = ( + "Options {added_options} were added at {timestep}. " + "Please update forecasts before {grace_period_end}; " + "existing forecasts will auto-withdraw then." + ) + + active_grace = has_active_grace_period(options_history) + action_choices = list(self.ACTION_CHOICES) + if active_grace: + action_choices = [ + choice + for choice in action_choices + if choice[0] in (self.ACTION_RENAME, self.ACTION_CHANGE_GRACE) + ] + else: + action_choices = [ + choice + for choice in action_choices + if choice[0] != self.ACTION_CHANGE_GRACE + ] + if len(options_history) > 1: + action_choices = [ + choice for choice in action_choices if choice[0] != self.ACTION_REORDER + ] + action = forms.ChoiceField( + choices=[("", "Select action")] + action_choices, + required=True, + initial="", + ) + self.fields["action"] = action + all_options = ( + get_all_options_from_history(options_history) if options_history else [] + ) + self.fields["old_option"].choices = [(opt, opt) for opt in all_options] + + current_options = question.options or [] + self.fields["options_to_delete"].choices = [ + (opt, opt) for opt in current_options + ] + self.reorder_field_names: list[tuple[str, str]] = [] + for index, option in enumerate(current_options): + field_name = f"reorder_position_{index}" + self.reorder_field_names.append((option, field_name)) + self.fields[field_name] = forms.IntegerField( + required=False, + min_value=1, + label=f"Order for '{option}'", + help_text="Use integers; options will be ordered ascending.", + ) + if current_options: + self.fields["options_to_delete"].widget.attrs["data-catch-all"] = ( + current_options[-1] + ) + self.fields["options_to_delete"].help_text = ( + "Warning: do not remove all options. The question should have at least " + "2 options: the last option you can't delete, and one other." + ) + grace_field = self.fields["grace_period_end"] + grace_field.widget = forms.DateTimeInput(attrs={"type": "datetime-local"}) + grace_initial = self.options_grace_period_end or ( + timezone.now() + timedelta(days=14) + ) + if grace_initial and timezone.is_naive(grace_initial): + grace_initial = timezone.make_aware(grace_initial) + grace_field.initial = timezone.localtime(grace_initial).strftime( + "%Y-%m-%dT%H:%M" + ) + if self.options_grace_period_end: + grace_field.help_text = ( + f"Current grace period end: " + f"{timezone.localtime(self.options_grace_period_end)}. " + "Provide a new end to extend or shorten." + ) + self.fields["delete_comment"].initial = default_delete_comment + self.fields["add_comment"].initial = default_add_comment + + def is_in_grace_period(self, reference_time=None): + reference_time = reference_time or timezone.now() + return bool( + self.options_grace_period_end + and self.options_grace_period_end > reference_time + ) + + def clean(self): + cleaned_data = super().clean() + question = self.question + action = cleaned_data.get("action") + current_options = question.options or [] + options_history = question.options_history or [] + now = timezone.now() + + if not question.options or not question.options_history: + raise forms.ValidationError( + "This question needs options and an options history to update." + ) + + if not action: + return cleaned_data + + if action == self.ACTION_RENAME: + old_option = cleaned_data.get("old_option") + new_option = cleaned_data.get("new_option", "") + + if not old_option: + self.add_error("old_option", "Select an option to rename.") + if not new_option or not new_option.strip(): + self.add_error("new_option", "Enter the new option text.") + new_option = (new_option or "").strip() + + if self.errors: + return cleaned_data + + if old_option not in current_options: + self.add_error( + "old_option", "Selected option is not part of the current choices." + ) + return cleaned_data + + new_options = [ + new_option if opt == old_option else opt for opt in current_options + ] + if len(set(new_options)) != len(new_options): + self.add_error( + "new_option", "New option duplicates an existing option." + ) + return cleaned_data + + cleaned_data["target_option"] = old_option + cleaned_data["parsed_new_option"] = new_option + return cleaned_data + + if action == self.ACTION_DELETE: + options_to_delete = cleaned_data.get("options_to_delete") or [] + catch_all_option = current_options[-1] if current_options else None + if not options_to_delete: + self.add_error( + "options_to_delete", "Select at least one option to delete." + ) + return cleaned_data + if catch_all_option and catch_all_option in options_to_delete: + self.add_error( + "options_to_delete", "The final catch-all option cannot be deleted." + ) + + new_options = [ + opt for opt in current_options if opt not in options_to_delete + ] + if len(new_options) < 2: + self.add_error( + "options_to_delete", + "At least one option in addition to the catch-all must remain.", + ) + if self.is_in_grace_period(now): + self.add_error( + "options_to_delete", + "Options cannot change during an active grace period.", + ) + + if self.errors: + return cleaned_data + + serializer = MultipleChoiceOptionsUpdateSerializer( + context={"question": question} + ) + try: + serializer.validate_new_options(new_options, options_history, None) + except DRFValidationError as exc: + raise forms.ValidationError(exc.detail or exc.args) + + cleaned_data["options_to_delete"] = options_to_delete + cleaned_data["delete_comment"] = cleaned_data.get("delete_comment", "") + return cleaned_data + + if action == self.ACTION_ADD: + new_options_raw = cleaned_data.get("new_options") or "" + grace_period_end = cleaned_data.get("grace_period_end") + if grace_period_end and timezone.is_naive(grace_period_end): + grace_period_end = timezone.make_aware(grace_period_end) + cleaned_data["grace_period_end"] = grace_period_end + new_options_list = [ + opt.strip() for opt in new_options_raw.split(",") if opt.strip() + ] + if not new_options_list: + self.add_error("new_options", "Enter at least one option to add.") + if len(new_options_list) != len(set(new_options_list)): + self.add_error("new_options", "New options list includes duplicates.") + + duplicate_existing = set(current_options).intersection(new_options_list) + if duplicate_existing: + self.add_error( + "new_options", + f"Options already exist: {', '.join(sorted(duplicate_existing))}", + ) + + if not grace_period_end: + self.add_error( + "grace_period_end", "Grace period end is required when adding." + ) + elif grace_period_end <= now: + self.add_error( + "grace_period_end", "Grace period end must be in the future." + ) + if self.is_in_grace_period(now): + self.add_error( + "grace_period_end", + "Options cannot change during an active grace period.", + ) + + if self.errors: + return cleaned_data + + serializer = MultipleChoiceOptionsUpdateSerializer( + context={"question": question} + ) + new_options = current_options[:-1] + new_options_list + current_options[-1:] + try: + serializer.validate_new_options( + new_options, options_history, grace_period_end + ) + except DRFValidationError as exc: + raise forms.ValidationError(exc.detail or exc.args) + + cleaned_data["new_options_list"] = new_options_list + cleaned_data["grace_period_end"] = grace_period_end + cleaned_data["add_comment"] = cleaned_data.get("add_comment", "") + return cleaned_data + + if action == self.ACTION_CHANGE_GRACE: + new_grace_end = cleaned_data.get("grace_period_end") + if new_grace_end and timezone.is_naive(new_grace_end): + new_grace_end = timezone.make_aware(new_grace_end) + cleaned_data["grace_period_end"] = new_grace_end + + if not new_grace_end: + self.add_error( + "grace_period_end", "New grace period end is required to change it." + ) + elif new_grace_end <= now: + self.add_error( + "grace_period_end", "Grace period end must be in the future." + ) + + if not self.is_in_grace_period(now): + self.add_error( + "grace_period_end", + "There is no active grace period to change.", + ) + + if self.errors: + return cleaned_data + + cleaned_data["new_grace_period_end"] = new_grace_end + return cleaned_data + + if action == self.ACTION_REORDER: + if len(options_history) > 1: + self.add_error( + "action", + "Options can only be reordered when there is a single options history entry.", + ) + return cleaned_data + + positions: dict[str, int] = {} + seen_values: set[int] = set() + + for option, field_name in getattr(self, "reorder_field_names", []): + value = cleaned_data.get(field_name) + if value is None: + self.add_error(field_name, "Enter an order value.") + continue + if value in seen_values: + self.add_error( + field_name, + "Order value must be unique.", + ) + continue + seen_values.add(value) + positions[option] = value + + if self.errors: + return cleaned_data + + if len(positions) != len(current_options): + raise forms.ValidationError("Provide an order value for every option.") + + desired_order = [ + option + for option, _ in sorted(positions.items(), key=lambda item: item[1]) + ] + cleaned_data["new_order"] = desired_order + return cleaned_data + + raise forms.ValidationError("Invalid action selected.") + + @admin.register(Question) class QuestionAdmin(CustomTranslationAdmin, DynamicArrayMixin): list_display = [ @@ -37,6 +436,7 @@ class QuestionAdmin(CustomTranslationAdmin, DynamicArrayMixin): "view_forecasts", "options", "options_history", + "update_mc_options", ] search_fields = [ "id", @@ -88,6 +488,22 @@ def view_forecasts(self, obj): url = reverse("admin:questions_forecast_changelist") + f"?question={obj.id}" return format_html('View Forecasts', url) + def update_mc_options(self, obj): + if not obj: + return "Save the question to manage options." + if obj.type != Question.QuestionType.MULTIPLE_CHOICE: + return "Option updates are available for multiple choice questions only." + if not obj.options_history or not obj.options: + return "Options and options history are required to update choices." + url = reverse("admin:questions_question_update_options", args=[obj.id]) + return format_html( + 'Update multiple choice options' + '

Rename, delete, or add options while keeping history.

', + url, + ) + + update_mc_options.short_description = "Multiple choice options" + def should_update_translations(self, obj): post = obj.get_post() is_private = post.default_project.default_permission is None @@ -95,12 +511,34 @@ def should_update_translations(self, obj): return not is_private and is_approved + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "/update-options/", + self.admin_site.admin_view(self.update_options_view), + name="questions_question_update_options", + ), + ] + return custom_urls + urls + def get_fields(self, request, obj=None): fields = super().get_fields(request, obj) + + def insert_after(target_field: str, new_field: str): + if new_field in fields: + fields.remove(new_field) + if target_field in fields: + fields.insert(fields.index(target_field) + 1, new_field) + else: + fields.append(new_field) + for field in ["post_link", "view_forecasts"]: if field in fields: fields.remove(field) fields.insert(0, field) + if obj: + insert_after("options_history", "update_mc_options") return fields def get_actions(self, request): @@ -136,6 +574,122 @@ def export_selected_questions_data_anonymized( ): return self.export_selected_questions_data(request, queryset, anonymized=True) + def update_options_view(self, request, question_id: int): + question = Question.objects.filter(pk=question_id).first() + if not question: + raise Http404("Question not found.") + if not self.has_change_permission(request, question): + raise PermissionDenied + + change_url = reverse("admin:questions_question_change", args=[question.id]) + if question.type != Question.QuestionType.MULTIPLE_CHOICE: + messages.error( + request, "Option updates are available for multiple choice questions." + ) + return HttpResponseRedirect(change_url) + if not question.options or not question.options_history: + messages.error( + request, + "Options and options history are required before updating choices.", + ) + return HttpResponseRedirect(change_url) + + form = MultipleChoiceOptionsAdminForm( + question, data=request.POST or None, prefix="options" + ) + if request.method == "POST" and form.is_valid(): + action = form.cleaned_data["action"] + if action == form.ACTION_RENAME: + old_option = form.cleaned_data["target_option"] + new_option = form.cleaned_data["parsed_new_option"] + multiple_choice_rename_option(question, old_option, new_option) + question.save(update_fields=["options", "options_history"]) + self.message_user( + request, f"Renamed option '{old_option}' to '{new_option}'." + ) + elif action == form.ACTION_REORDER: + new_order = form.cleaned_data["new_order"] + multiple_choice_reorder_options(question, new_order) + question.save(update_fields=["options", "options_history"]) + self.message_user( + request, + "Reordered options.", + ) + elif action == form.ACTION_DELETE: + options_to_delete = form.cleaned_data["options_to_delete"] + delete_comment = form.cleaned_data.get("delete_comment", "") + multiple_choice_delete_options( + question, + options_to_delete, + comment_author=request.user, + timestep=timezone.now(), + comment_text=delete_comment, + ) + question.save(update_fields=["options", "options_history"]) + self.message_user( + request, + f"Deleted {len(options_to_delete)} option" + f"{'' if len(options_to_delete) == 1 else 's'}.", + ) + elif action == form.ACTION_ADD: + new_options = form.cleaned_data["new_options_list"] + grace_period_end = form.cleaned_data["grace_period_end"] + add_comment = form.cleaned_data.get("add_comment", "") + if timezone.is_naive(grace_period_end): + grace_period_end = timezone.make_aware(grace_period_end) + multiple_choice_add_options( + question, + new_options, + grace_period_end=grace_period_end, + comment_author=request.user, + timestep=timezone.now(), + comment_text=add_comment, + ) + question.save(update_fields=["options", "options_history"]) + self.message_user( + request, + f"Added {len(new_options)} option" + f"{'' if len(new_options) == 1 else 's'}.", + ) + elif action == form.ACTION_CHANGE_GRACE: + new_grace_period_end = form.cleaned_data["new_grace_period_end"] + if timezone.is_naive(new_grace_period_end): + new_grace_period_end = timezone.make_aware(new_grace_period_end) + multiple_choice_change_grace_period_end( + question, + new_grace_period_end, + comment_author=request.user, + timestep=timezone.now(), + ) + question.save(update_fields=["options_history"]) + self.message_user( + request, + f"Grace period end updated to {timezone.localtime(new_grace_period_end)}.", + ) + return HttpResponseRedirect(change_url) + + grace_period_end = form.options_grace_period_end + in_grace_period = form.is_in_grace_period() + + context = { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "app_label": self.model._meta.app_label, + "original": question, + "question": question, + "title": f"Update options for {question}", + "form": form, + "media": self.media + form.media, + "change_url": change_url, + "current_options": question.options or [], + "all_history_options": get_all_options_from_history( + question.options_history + ), + "grace_period_end": grace_period_end, + "in_grace_period": in_grace_period, + } + return TemplateResponse(request, "admin/questions/update_options.html", context) + def rebuild_aggregation_history(self, request, queryset: QuerySet[Question]): for question in queryset: build_question_forecasts(question) diff --git a/questions/serializers/common.py b/questions/serializers/common.py index d8fb56420a..2cc5fe846c 100644 --- a/questions/serializers/common.py +++ b/questions/serializers/common.py @@ -232,6 +232,23 @@ class Meta(QuestionWriteSerializer.Meta): "cp_reveal_time", ) + def validate(self, data: dict): + data = super().validate(data) + + if qid := data.get("id"): + question = Question.objects.get(id=qid) + if data.get("options") != question.options: + # if there are user forecasts, we can't update options this way + if question.user_forecasts.exists(): + ValidationError( + "Cannot update options through this endpoint while there are " + "user forecasts. " + "Instead, use /api/questions/update-mc-options/ or the UI on " + "the question detail page." + ) + + return data + # TODO: add validation for updating continuous question bounds diff --git a/questions/services/multiple_choice_handlers.py b/questions/services/multiple_choice_handlers.py index b36050a4e8..cff4b4286a 100644 --- a/questions/services/multiple_choice_handlers.py +++ b/questions/services/multiple_choice_handlers.py @@ -7,6 +7,78 @@ from questions.models import Question, Forecast from questions.types import OptionsHistoryType +# MOVE THIS serializer imports +from rest_framework import serializers +from collections import Counter +from rest_framework.exceptions import ValidationError +from users.models import User + + +class MultipleChoiceOptionsUpdateSerializer(serializers.Serializer): + options = serializers.ListField(child=serializers.CharField(), required=True) + grace_period_end = serializers.DateTimeField(required=False) + + def validate_new_options( + self, + new_options: list[str], + options_history: OptionsHistoryType, + grace_period_end: datetime | None = None, + ): + datetime_str, current_options = options_history[-1] + ts = ( + datetime.fromisoformat(datetime_str) + .replace(tzinfo=dt_timezone.utc) + .timestamp() + ) + if new_options == current_options: # no change + return + if len(new_options) == len(current_options): # renaming + if any(v > 1 for v in Counter(new_options).values()): + ValidationError("new_options includes duplicate labels") + elif timezone.now().timestamp() < ts: + raise ValidationError("options cannot change during a grace period") + elif len(new_options) < len(current_options): # deletion + if len(new_options) < 2: + raise ValidationError("Must have 2 or more options") + if new_options[-1] != current_options[-1]: + raise ValidationError("Cannot delete last option") + if [l for l in new_options if l not in current_options]: + raise ValidationError( + "options cannot change name while some are being deleted" + ) + elif len(new_options) > len(current_options): # addition + if not grace_period_end or grace_period_end <= timezone.now(): + raise ValidationError( + "grace_period_end must be in the future if adding options" + ) + if new_options[-1] != current_options[-1]: + raise ValidationError("Cannot add option after last option") + if [l for l in current_options if l not in new_options]: + raise ValidationError( + "options cannot change name while some are being added" + ) + + def validate(self, data: dict) -> dict: + question: Question = self.context.get("question") + if not question: + raise ValidationError(f"question must be provided in context") + + if question.type != Question.QuestionType.MULTIPLE_CHOICE: + raise ValidationError("question must be of multiple choice type") + + options = data.get("options") + options_history = question.options_history + if not options or not options_history: + raise ValidationError( + "updating multiple choice questions requires options " + "and question must already have options_history" + ) + + grace_period_end = data.get("grace_period_end") + self.validate_new_options(options, options_history, grace_period_end) + + return data + def get_all_options_from_history( options_history: OptionsHistoryType | None, @@ -113,10 +185,16 @@ def multiple_choice_reorder_options( return question +def multiple_choice_change_grace_period_end(*args, **kwargs): + raise NotImplementedError("multiple_choice_change_grace_period_end") + + def multiple_choice_delete_options( question: Question, options_to_delete: list[str], + comment_author: User, timestep: datetime | None = None, + comment_text: str | None = None, ) -> Question: """ Modifies question in place and returns it. @@ -215,7 +293,8 @@ def multiple_choice_delete_options( multiple_choice_delete_option_notificiations( question_id=question.id, timestep=timestep, - comment_author_id=4, # placeholder id + comment_author_id=comment_author.id, + comment_text=comment_text, ) return question @@ -225,7 +304,9 @@ def multiple_choice_add_options( question: Question, options_to_add: list[str], grace_period_end: datetime, + comment_author: User, timestep: datetime | None = None, + comment_text: str | None = None, ) -> Question: """ Modifies question in place and returns it. @@ -288,7 +369,8 @@ def multiple_choice_add_options( question_id=question.id, grace_period_end=grace_period_end, timestep=timestep, - comment_author_id=4, # placeholder id + comment_author_id=comment_author.id, + comment_text=comment_text, ) return question diff --git a/questions/tasks.py b/questions/tasks.py index f1969417d6..2df66d78a4 100644 --- a/questions/tasks.py +++ b/questions/tasks.py @@ -264,6 +264,7 @@ def multiple_choice_delete_option_notificiations( question_id: int, timestep: datetime, comment_author_id: int, + comment_text: str | None = None, ): question = Question.objects.get(id=question_id) post = question.get_post() @@ -272,14 +273,17 @@ def multiple_choice_delete_option_notificiations( # 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}." - ), + default_text = ( + "Options {removed_options} were removed at {timestep}. " + "Forecasts were adjusted to keep remaining probability on the catch-all." ) + template = comment_text or default_text + try: + text = template.format(removed_options=removed_options, timestep=timestep) + except Exception: + text = f"{template} (removed options: {removed_options}, at {timestep})" + + create_comment(comment_author, post, text=text) # # send out an immediate email # forecaster_emails = ( @@ -323,6 +327,7 @@ def multiple_choice_add_option_notificiations( grace_period_end: datetime, timestep: datetime, comment_author_id: int, + comment_text: str | None = None, ): question = Question.objects.get(id=question_id) post = question.get_post() @@ -348,19 +353,25 @@ def multiple_choice_add_option_notificiations( # 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." - ), + default_text = ( + "Options {added_options} were added at {timestep}. " + "Please update forecasts before {grace_period_end}, when existing " + "forecasts will auto-withdraw." ) + template = comment_text or default_text + try: + text = template.format( + added_options=added_options, + timestep=timestep, + grace_period_end=grace_period_end, + ) + except Exception: + text = ( + f"{template} (added options: {added_options}, at {timestep}, " + f"grace ends: {grace_period_end})" + ) + + create_comment(comment_author, post, text=text) # # send out an immediate email # forecaster_emails = forecasters.values_list("email", flat=True) diff --git a/templates/admin/questions/update_options.html b/templates/admin/questions/update_options.html new file mode 100644 index 0000000000..7d7a426248 --- /dev/null +++ b/templates/admin/questions/update_options.html @@ -0,0 +1,182 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls %} + +{% block extrahead %} +{{ block.super }} +{{ media }} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} + +
+

Current options:

+ +

All options ever used:

+ + + {% if in_grace_period %} +
+ This question is in an active grace period until {{ grace_period_end|date:"DATETIME_FORMAT" }}. + You can rename options, change the grace period end, but adding or deleting options is temporarily disabled. +
+ {% endif %} + +
+ {% csrf_token %} + {{ form.non_field_errors }} +
+ {% for field in form %} +
+ {{ field.errors }} +
+ {{ field.label_tag }} + {{ field }} + {% if field.help_text %}

{{ field.help_text }}

{% endif %} +
+
+ {% endfor %} +
+ + + +
+
+ +{% endblock %} diff --git a/tests/unit/test_questions/test_services/test_multiple_choice_handlers.py b/tests/unit/test_questions/test_services/test_multiple_choice_handlers.py index 2070530b87..e36cf03d45 100644 --- a/tests/unit/test_questions/test_services/test_multiple_choice_handlers.py +++ b/tests/unit/test_questions/test_services/test_multiple_choice_handlers.py @@ -249,11 +249,13 @@ def test_multiple_choice_delete_options( if not expect_success: with pytest.raises(ValueError): multiple_choice_delete_options( - question, options_to_delete, timestep=timestep + question, options_to_delete, comment_author=user1, timestep=timestep ) return - multiple_choice_delete_options(question, options_to_delete, timestep=timestep) + multiple_choice_delete_options( + question, options_to_delete, comment_author=user1, timestep=timestep + ) question.refresh_from_db() expected_options = [opt for opt in initial_options if opt not in options_to_delete] @@ -417,12 +419,20 @@ def test_multiple_choice_add_options( if not expect_success: with pytest.raises(ValueError): multiple_choice_add_options( - question, options_to_add, grace_period_end, timestep=dt(2024, 7, 1) + question, + options_to_add, + grace_period_end, + comment_author=user1, + timestep=dt(2024, 7, 1), ) return multiple_choice_add_options( - question, options_to_add, grace_period_end, timestep=dt(2024, 7, 1) + question, + options_to_add, + grace_period_end, + comment_author=user1, + timestep=dt(2024, 7, 1), ) question.refresh_from_db()