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
),
]
75 changes: 60 additions & 15 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):
# 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 Expand Up @@ -596,14 +637,16 @@ def get_prediction_values(self) -> list[float | None]:
return self.probability_yes_per_category
return self.continuous_cdf

def get_pmf(self) -> list[float]:
def get_pmf(self, replace_none: bool = False) -> list[float]:
"""
gets the PMF for this forecast, replacing None values with 0.0
Not for serialization use (keep None values in that case)
gets the PMF for this forecast
replaces None values with 0.0 if replace_none is True
"""
if self.probability_yes:
return [1 - self.probability_yes, self.probability_yes]
if self.probability_yes_per_category:
if not replace_none:
return self.probability_yes_per_category
return [
v or 0.0 for v in self.probability_yes_per_category
] # replace None with 0.0
Expand Down Expand Up @@ -678,18 +721,20 @@ def get_cdf(self) -> list[float | None] | None:
return self.forecast_values
return None

def get_pmf(self) -> list[float]:
def get_pmf(self, replace_none: bool = False) -> list[float | None]:
"""
gets the PMF for this forecast, replacing None values with 0.0
Not for serialization use (keep None values in that case)
gets the PMF for this forecast
replacing None values with 0.0 if replace_none is True
"""
# grab annotation if it exists for efficiency
question_type = getattr(self, "question_type", self.question.type)
forecast_values = [
v or 0.0 for v in self.forecast_values
] # replace None with 0.0
forecast_values = self.forecast_values
if question_type == Question.QuestionType.MULTIPLE_CHOICE:
if not replace_none:
return forecast_values
return [v or 0.0 for v in forecast_values] # replace None with 0.0
if question_type in QUESTION_CONTINUOUS_TYPES:
cdf: list[float] = forecast_values
cdf: list[float] = forecast_values # type: ignore
pmf = [cdf[0]]
for i in range(1, len(cdf)):
pmf.append(cdf[i] - cdf[i - 1])
Expand Down
Loading