Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,40 @@ components:
items:
type: string
description: "List of options for multiple_choice questions"
example:
- "Democratic"
- "Republican"
- "Libertarian"
- "Green"
- "Other"
all_options_ever:
type: array
items:
type: string
description: "List of all options ever for multiple_choice questions"
example:
- "Democratic"
- "Republican"
- "Libertarian"
- "Green"
- "Blue"
- "Other"
options_history:
type: array
description: "List of [iso format time, options] pairs for multiple_choice questions"
items:
type: array
items:
oneOf:
- type: string
description: "ISO 8601 timestamp when the options became active"
- type: array
items:
type: string
description: "Options list active from this timestamp onward"
example:
- ["0001-01-01T00:00:00", ["a", "b", "c", "other"]]
- ["2026-10-22T16:00:00", ["a", "b", "c", "d", "other"]]
status:
type: string
enum: [ upcoming, open, closed, resolved ]
Expand Down Expand Up @@ -1306,6 +1340,7 @@ paths:
actual_close_time: "2020-11-01T00:00:00Z"
type: "numeric"
options: null
options_history: null
status: "resolved"
resolution: "77289125.94957079"
resolution_criteria: "Resolution Criteria Copy"
Expand Down Expand Up @@ -1479,6 +1514,7 @@ paths:
actual_close_time: "2015-12-15T03:34:00Z"
type: "binary"
options: null
options_history: null
status: "resolved"
possibilities:
type: "binary"
Expand Down Expand Up @@ -1548,6 +1584,16 @@ paths:
- "Libertarian"
- "Green"
- "Other"
all_options_ever:
- "Democratic"
- "Republican"
- "Libertarian"
- "Green"
- "Blue"
- "Other"
options_history:
- ["0001-01-01T00:00:00", ["Democratic", "Republican", "Libertarian", "Other"]]
- ["2026-10-22T16:00:00", ["Democratic", "Republican", "Libertarian", "Green", "Other"]]
status: "open"
possibilities: { }
resolution: null
Expand Down
4 changes: 3 additions & 1 deletion misc/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ def get_site_stats(request):
now_year = datetime.now().year
public_questions = Question.objects.filter_public()
stats = {
"predictions": Forecast.objects.filter(question__in=public_questions).count(),
"predictions": Forecast.objects.filter(question__in=public_questions)
.exclude(source=Forecast.SourceChoices.AUTOMATIC)
.count(),
"questions": public_questions.count(),
"resolved_questions": public_questions.filter(actual_resolve_time__isnull=False)
.exclude(resolution__in=UnsuccessfulResolutionType)
Expand Down
6 changes: 5 additions & 1 deletion posts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,11 @@ def update_forecasts_count(self):
Update forecasts count cache
"""

self.forecasts_count = self.forecasts.filter_within_question_period().count()
self.forecasts_count = (
self.forecasts.filter_within_question_period()
.exclude(source=Forecast.SourceChoices.AUTOMATIC)
.count()
)
self.save(update_fields=["forecasts_count"])

def update_forecasters_count(self):
Expand Down
7 changes: 6 additions & 1 deletion questions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ class QuestionAdmin(CustomTranslationAdmin, DynamicArrayMixin):
"curation_status",
"post_link",
]
readonly_fields = ["post_link", "view_forecasts"]
readonly_fields = [
"post_link",
"view_forecasts",
"options",
"options_history",
]
search_fields = [
"id",
"title_original",
Expand Down
2 changes: 1 addition & 1 deletion questions/migrations/0013_forecast_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Migration(migrations.Migration):
name="source",
field=models.CharField(
blank=True,
choices=[("api", "Api"), ("ui", "Ui")],
choices=[("api", "Api"), ("ui", "Ui"), ("automatic", "Automatic")],
default="",
max_length=30,
null=True,
Expand Down
50 changes: 50 additions & 0 deletions questions/migrations/0033_question_options_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Generated by Django 5.1.13 on 2025-11-15 19:35
from datetime import datetime


import questions.models
from django.db import migrations, models


def initialize_options_history(apps, schema_editor):
Question = apps.get_model("questions", "Question")
questions = Question.objects.filter(options__isnull=False)
for question in questions:
if question.options:
question.options_history = [(datetime.min.isoformat(), question.options)]
Question.objects.bulk_update(questions, ["options_history"])


class Migration(migrations.Migration):

dependencies = [
("questions", "0032_alter_aggregateforecast_forecast_values_and_more"),
]

operations = [
migrations.AlterField(
model_name="forecast",
name="source",
field=models.CharField(
blank=True,
choices=[("api", "Api"), ("ui", "Ui"), ("automatic", "Automatic")],
db_index=True,
default="",
max_length=30,
null=True,
),
),
migrations.AddField(
model_name="question",
name="options_history",
field=models.JSONField(
blank=True,
help_text="For Multiple Choice only.\n <br>list of tuples: (isoformat_datetime, options_list). (json stores them as lists)\n <br>Records the history of options over time.\n <br>Initialized with (datetime.min.isoformat(), self.options) upon question creation.\n <br>Updated whenever options are changed.",
null=True,
validators=[questions.models.validate_options_history],
),
),
migrations.RunPython(
initialize_options_history, reverse_code=migrations.RunPython.noop
),
]
51 changes: 46 additions & 5 deletions questions/models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from datetime import datetime, timedelta
from typing import TYPE_CHECKING

from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Count, QuerySet, Q, F, Exists, OuterRef
from django.utils import timezone
from django_better_admin_arrayfield.models.fields import ArrayField
from sql_util.aggregates import SubqueryAggregate

from questions.constants import QuestionStatus
from questions.types import AggregationMethod
from questions.types import AggregationMethod, OptionsHistoryType
from scoring.constants import ScoreTypes
from users.models import User
from utils.models import TimeStampedModel, TranslatedModel
Expand All @@ -20,6 +21,27 @@
DEFAULT_INBOUND_OUTCOME_COUNT = 200


def validate_options_history(value):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this out of models to the new utils/validators.py module

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any validators.py module

# Expect: [ (float, [str, ...]), ... ] or equivalent
if not isinstance(value, list):
raise ValidationError("Must be a list.")
for i, item in enumerate(value):
if (
not isinstance(item, (list, tuple))
or len(item) != 2
or not isinstance(item[0], str)
or not isinstance(item[1], list)
or not all(isinstance(s, str) for s in item[1])
):
raise ValidationError(f"Bad item at index {i}: {item!r}")
try:
datetime.fromisoformat(item[0])
except ValueError:
raise ValidationError(
f"Bad datetime format at index {i}: {item[0]!r}, must be isoformat string"
)


class QuestionQuerySet(QuerySet):
def annotate_forecasts_count(self):
return self.annotate(
Expand Down Expand Up @@ -197,8 +219,20 @@ class QuestionType(models.TextChoices):
)
unit = models.CharField(max_length=25, blank=True)

# list of multiple choice option labels
options = ArrayField(models.CharField(max_length=200), blank=True, null=True)
# multiple choice fields
options: list[str] | None = ArrayField(
models.CharField(max_length=200), blank=True, null=True
)
options_history: OptionsHistoryType | None = models.JSONField(
null=True,
blank=True,
validators=[validate_options_history],
help_text="""For Multiple Choice only.
<br>list of tuples: (isoformat_datetime, options_list). (json stores them as lists)
<br>Records the history of options over time.
<br>Initialized with (datetime.min.isoformat(), self.options) upon question creation.
<br>Updated whenever options are changed.""",
)

# Legacy field that will be removed
possibilities = models.JSONField(null=True, blank=True)
Expand Down Expand Up @@ -251,6 +285,9 @@ def save(self, **kwargs):
self.zero_point = None
if self.type != self.QuestionType.MULTIPLE_CHOICE:
self.options = None
if self.type == self.QuestionType.MULTIPLE_CHOICE and not self.options_history:
# initialize options history on first save
self.options_history = [(datetime.min.isoformat(), self.options or [])]

return super().save(**kwargs)

Expand Down Expand Up @@ -545,8 +582,11 @@ class Forecast(models.Model):
)

class SourceChoices(models.TextChoices):
API = "api"
UI = "ui"
API = "api" # made via the api
UI = "ui" # made using the api
# an automatically assigned forecast
# usually this means a regular forecast was split
AUTOMATIC = "automatic"

# logging the source of the forecast for data purposes
source = models.CharField(
Expand All @@ -555,6 +595,7 @@ class SourceChoices(models.TextChoices):
null=True,
choices=SourceChoices.choices,
default="",
db_index=True,
)

distribution_input = models.JSONField(
Expand Down
12 changes: 9 additions & 3 deletions questions/serializers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@
AggregateForecast,
Forecast,
)
from questions.serializers.aggregate_forecasts import (
serialize_question_aggregations,
)
from questions.serializers.aggregate_forecasts import serialize_question_aggregations
from questions.services.multiple_choice_handlers import get_all_options_from_history
from questions.types import QuestionMovement
from users.models import User
from utils.the_math.formulas import (
Expand All @@ -40,6 +39,7 @@ class QuestionSerializer(serializers.ModelSerializer):
actual_close_time = serializers.SerializerMethodField()
resolution = serializers.SerializerMethodField()
spot_scoring_time = serializers.SerializerMethodField()
all_options_ever = serializers.SerializerMethodField()

class Meta:
model = Question
Expand All @@ -58,6 +58,8 @@ class Meta:
"type",
# Multiple-choice Questions only
"options",
"all_options_ever",
"options_history",
"group_variable",
# Used for Group Of Questions to determine
# whether question is eligible for forecasting
Expand Down Expand Up @@ -122,6 +124,10 @@ def get_actual_close_time(self, question: Question):
return min(question.scheduled_close_time, question.actual_resolve_time)
return question.scheduled_close_time

def get_all_options_ever(self, question: Question):
if question.options_history:
return get_all_options_from_history(question.options_history)

def get_resolution(self, question: Question):
resolution = question.resolution

Expand Down
Loading