Current options:
+-
+ {% for opt in current_options %}
+
- {{ opt }} + {% endfor %} +
All options ever used:
+-
+ {% for opt in all_history_options %}
+
- {{ opt }} + {% endfor %} +
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( + "Current options:
+All options ever used:
+