diff --git a/comments/services/common.py b/comments/services/common.py index e2141b93e1..72c952fe21 100644 --- a/comments/services/common.py +++ b/comments/services/common.py @@ -130,7 +130,9 @@ def create_comment( return obj -def update_comment(comment: Comment, text: str = None): +def update_comment( + comment: Comment, text: str = None, included_forecast: Forecast = None +): differ = difflib.Differ() diff = list(differ.compare(comment.text.splitlines(), text.splitlines())) @@ -148,10 +150,19 @@ def update_comment(comment: Comment, text: str = None): comment.text = text comment.text_edited_at = timezone.now() + if included_forecast: + comment.included_forecast = included_forecast + should_soft_delete = check_and_handle_comment_spam(comment.author, comment) comment.save( - update_fields=["text", "edit_history", "text_edited_at", "is_soft_deleted"] + update_fields=[ + "text", + "edit_history", + "text_edited_at", + "is_soft_deleted", + "included_forecast", + ] ) if should_soft_delete: diff --git a/comments/views/common.py b/comments/views/common.py index 72a10a1e85..572d64143d 100644 --- a/comments/views/common.py +++ b/comments/views/common.py @@ -1,6 +1,7 @@ from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404 +from django.utils import timezone from rest_framework import serializers, status from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import PermissionDenied, ValidationError @@ -166,13 +167,35 @@ def comment_edit_api_view(request: Request, pk: int): # Small validation comment = get_object_or_404(Comment, pk=pk) text = serializers.CharField().run_validation(request.data.get("text")) + include_forecast = serializers.BooleanField( + required=False, allow_null=True + ).run_validation(request.data.get("include_forecast")) if not (comment.author == request.user): raise PermissionDenied("You do not have permission to edit this comment.") - update_comment(comment, text) + post = comment.on_post + forecast = None - return Response({}, status=status.HTTP_200_OK) + if include_forecast and not comment.included_forecast and post and post.question_id: + active_time = comment.created_at + question = post.question + + # If question was closed, take the forecast active on the date of closure + if question.actual_close_time and question.actual_close_time <= timezone.now(): + active_time = question.actual_close_time + + forecast = ( + question.user_forecasts.filter(author=comment.author) + .filter_active_at(active_time) + .order_by("-start_time") + .first() + ) + + update_comment(comment, text, included_forecast=forecast) + comment.refresh_from_db() + + return Response(serialize_comment(comment), status=status.HTTP_200_OK) @api_view(["POST"]) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 4275efc2f2..30591a4997 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -1819,5 +1819,9 @@ "tournamentsTabArchived": "Archivováno", "tournamentTimelineClosed": "Čekání na vyřešení", "questionsPreviouslyPredicted": "{count, plural, =1 {# otázka} other {# otázek}} dříve předpovězených", + "includeMyForecastAtTheTime": "Zahrnout mou předpověď v daném čase ({forecast})", + "tournamentsInfoTitle": "Jsme nepredikční trh. Můžete se účastnit zdarma a vyhrát peněžní ceny za přesnost.", + "tournamentsInfoScoringLink": "Co jsou předpovídací skóre?", + "tournamentsInfoPrizesLink": "Jak jsou rozdělovány ceny?", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 7287677ab3..e20265c86a 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -714,6 +714,7 @@ "legacyPeerDisclaimer": "The Peer Accuracy leaderboards used slightly different math before 2024. See details here.", "namesPrediction": "{username}'s Prediction", "includeMyForecast": "include your current forecast in this comment", + "includeMyForecastAtTheTime": "Include my prediction at the time ({forecast})", "privateComment": "private comment", "groupVariable": "Group Variable", "questionUnit": "Question Unit", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 86c28fee24..a6c2788afa 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -1819,5 +1819,9 @@ "tournamentsTabArchived": "Archivado", "tournamentTimelineClosed": "Esperando resoluciones", "questionsPreviouslyPredicted": "{count, plural, =1 {# pregunta} other {# preguntas}} previamente previstas", + "includeMyForecastAtTheTime": "Incluir mi predicción en ese momento ({forecast})", + "tournamentsInfoTitle": "Nosotros no somos un mercado de predicciones. Puedes participar gratis y ganar premios en efectivo por ser preciso.", + "tournamentsInfoScoringLink": "¿Qué son las puntuaciones de predicción?", + "tournamentsInfoPrizesLink": "¿Cómo se distribuyen los premios?", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 4e519ae893..99a3b22766 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -1817,5 +1817,9 @@ "tournamentsTabArchived": "Arquivado", "tournamentTimelineClosed": "Aguardando resoluções", "questionsPreviouslyPredicted": "{count, plural, =1 {# pergunta} other {# perguntas}} previamente previstas", + "includeMyForecastAtTheTime": "Incluir minha previsão no momento ({forecast})", + "tournamentsInfoTitle": "Nós não somos um mercado de previsões. Você pode participar gratuitamente e ganhar prêmios em dinheiro por ser preciso.", + "tournamentsInfoScoringLink": "O que são pontuações de previsão?", + "tournamentsInfoPrizesLink": "Como os prêmios são distribuídos?", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 1e1cdb9443..8ba88fc0df 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -1816,5 +1816,9 @@ "tournamentsTabArchived": "已存檔", "tournamentTimelineClosed": "等待裁定", "questionsPreviouslyPredicted": "先前預測的{count, plural, =1 {# 個問題} other {# 個問題}}", + "includeMyForecastAtTheTime": "包括我當時的預測 ({forecast})", + "tournamentsInfoTitle": "我們 不是預測市場。您可以免費參加並因精確的預測贏取現金獎勵。", + "tournamentsInfoScoringLink": "什麼是預測得分?", + "tournamentsInfoPrizesLink": "獎品如何分配?", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 6110ac7638..8b579b3602 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -1821,5 +1821,9 @@ "tournamentsTabArchived": "已归档", "tournamentTimelineClosed": "等待解决", "questionsPreviouslyPredicted": "之前预测的{count, plural, =1 {# 个问题} other {# 个问题}}", + "includeMyForecastAtTheTime": "包含我当时的预测 ({forecast})", + "tournamentsInfoTitle": "我们不是一个预测市场。您可以免费参与,并因精准的预测赢得现金奖品。", + "tournamentsInfoScoringLink": "什么是预测分数?", + "tournamentsInfoPrizesLink": "奖品如何分配?", "othersCount": "其他({count})" } diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index aad166e2b9..57ceaf8f14 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -29,7 +29,9 @@ import CommentVoter from "@/components/comment_feed/comment_voter"; import { Admin } from "@/components/icons/admin"; import { Moderator } from "@/components/icons/moderator"; import MarkdownEditor from "@/components/markdown_editor"; +import RichText from "@/components/rich_text"; import Button from "@/components/ui/button"; +import Checkbox from "@/components/ui/checkbox"; import DropdownMenu, { MenuItemProps } from "@/components/ui/dropdown_menu"; import { userTagPattern } from "@/constants/comments"; import { useAuth } from "@/contexts/auth_context"; @@ -49,12 +51,16 @@ import { sendAnalyticsEvent } from "@/utils/analytics"; import { parseUserMentions } from "@/utils/comments"; import cn from "@/utils/core/cn"; import { logError } from "@/utils/core/errors"; +import { isForecastActive } from "@/utils/forecasts/helpers"; import { formatUsername } from "@/utils/formatters/users"; import { getMarkdownSummary } from "@/utils/markdown"; import { canPredictQuestion } from "@/utils/questions/predictions"; import { CmmOverlay, CmmToggleButton, useCmmContext } from "./comment_cmm"; -import IncludedForecast from "./included_forecast"; +import IncludedForecast, { + formatForecastValueText, + userForecastToForecastType, +} from "./included_forecast"; import { validateComment } from "./validate_comment"; import { FormErrorMessage } from "../ui/form_field"; import LoadingSpinner from "../ui/loading_spiner"; @@ -233,6 +239,10 @@ const Comment: FC = ({ const [errorMessage, setErrorMessage] = useState(); const [commentMarkdown, setCommentMarkdown] = useState(comment.text); const [tempCommentMarkdown, setTempCommentMarkdown] = useState(""); + const [includeEditForecast, setIncludeEditForecast] = useState(false); + const [includedForecast, setIncludedForecast] = useState( + comment.included_forecast + ); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const { ref, width } = useContainerSize(); const { PUBLIC_MINIMAL_UI } = usePublicSettings(); @@ -242,12 +252,44 @@ const Comment: FC = ({ const userForecast = postData?.question?.my_forecasts?.latest?.forecast_values[1] ?? 0.5; const isCommentAuthor = comment.author.id === user?.id; + + // Check if user had a forecast active at comment creation time + const commentCreatedAt = new Date(comment.created_at).getTime() / 1000; + const hadForecastAtCommentCreation = useMemo(() => { + if (comment.author.id !== user?.id) { + return false; + } + + const forecasts = postData?.question?.my_forecasts?.history || []; + + return forecasts.find((forecast) => { + const startTime = forecast.start_time; + const endTime = forecast.end_time; + return ( + startTime <= commentCreatedAt && + (endTime === null || endTime > commentCreatedAt) + ); + }); + }, [ + comment.author.id, + user?.id, + postData?.question?.my_forecasts?.history, + commentCreatedAt, + ]); + const isCmmButtonVisible = !!postData?.question || !!postData?.group_of_questions || !!postData?.conditional; const isCmmButtonDisabled = !user || !userCanPredict || isCommentAuthor; + const canIncludeForecastInReply = useMemo(() => { + if (!postData?.question) return false; + if (postData.question.type === QuestionType.MultipleChoice) return false; + const latest = postData.question.my_forecasts?.latest; + return !!latest && isForecastActive(latest); + }, [postData]); + const { draftReady: editDraftReady, initialMarkdown: editInitialMarkdown, @@ -389,12 +431,16 @@ const Comment: FC = ({ }; const openEdit = useCallback(() => { + const scrollY = window.scrollY; setTempCommentMarkdown(originalTextRef.current); setIsEditing(true); setEditorKey((k) => k + 1); setCommentMarkdown( editDraftReady ? editInitialMarkdown : originalTextRef.current ); + requestAnimationFrame(() => { + window.scrollTo(0, scrollY); + }); }, [editDraftReady, editInitialMarkdown]); const updateForecast = async (value: number) => { @@ -447,6 +493,7 @@ const Comment: FC = ({ id: comment.id, text: parsedMarkdown, author: user.id, + include_forecast: includeEditForecast, }); if (response && "errors" in response) { setErrorMessage(response.errors as ErrorResponse); @@ -460,6 +507,17 @@ const Comment: FC = ({ setTempCommentMarkdown(parsedMarkdown); setEditInitialMarkdown(parsedMarkdown); setIsEditing(false); + setIncludeEditForecast(false); + if ( + response && + "included_forecast" in response && + response.included_forecast + ) { + setIncludedForecast({ + ...response.included_forecast, + start_time: new Date(response.included_forecast.start_time), + }); + } deleteEditDraft(); } } finally { @@ -477,6 +535,7 @@ const Comment: FC = ({ setEditInitialMarkdown, setIsEditing, deleteEditDraft, + includeEditForecast, ]); // scroll to comment from URL hash useEffect(() => { @@ -738,10 +797,10 @@ const Comment: FC = ({ */} {/* comment indexing is broken, since the comment feed loading happens async for the client*/} - {comment.included_forecast && !isCollapsed && ( + {includedForecast && !isCollapsed && !isEditing && ( )} @@ -760,173 +819,215 @@ const Comment: FC = ({ <>
{isEditing && ( - { - setCommentMarkdown(val); - saveEditDraftDebounced(val); - }} - withUgcLinks - withCodeBlocks - /> - )}{" "} - {!isEditing && !(isTextEmpty && commentKeyFactors.length > 0) && ( - +
+ { + setCommentMarkdown(val); + saveEditDraftDebounced(val); + }} + withUgcLinks + withCodeBlocks + /> + {hadForecastAtCommentCreation && postData?.question && ( + + {(tags) => ( + + setIncludeEditForecast(checked) + } + label="includeMyForecastAtTheTime" + className="ml-auto mt-2 w-fit text-sm" + disabled={!!includedForecast} + > + + {t.rich("includeMyForecastAtTheTime", { + ...tags, + forecast: formatForecastValueText( + userForecastToForecastType( + hadForecastAtCommentCreation, + postData.question + ) + ), + })} + + + )} + + )} +
+ {!!errorMessage && ( + )} - mode="read" - withUgcLinks - withTwitterPreview - withCodeBlocks - /> +
+ + +
+
)} - {!!errorMessage && isEditing && ( - - )} - {isEditing && ( - <> - - - - )} - {commentKeyFactors.length > 0 && canListKeyFactors && postData && ( - - )} - -
-
-
- + {!(isTextEmpty && commentKeyFactors.length > 0) && ( + - - {canShowAddKeyFactorsButton && ( -
- - - )} + <> +
+ +
+
+ + + {t("addKeyFactor")} + + {t("add")} +
+ + + )} - {!onProfile && - (isReplying ? ( - + ) : ( + + ))} + + {isCmmButtonVisible && ( + - {t("cancel")} - - ) : ( - - ))} - - {isCmmButtonVisible && ( - - )} -
+ )} +
-
0 && "pr-1.5 md:pr-2")}> - +
0 && "pr-1.5 md:pr-2")}> + +
+
- - + + )} )} @@ -940,6 +1041,7 @@ const Comment: FC = ({ setIsReplying(false); }} isReplying={isReplying} + shouldIncludeForecast={canIncludeForecastInReply} /> )} {isKeyfactorsFormOpen && postData && ( diff --git a/front_end/src/components/comment_feed/comment_editor.tsx b/front_end/src/components/comment_feed/comment_editor.tsx index 9350fbdb6c..e44e02277f 100644 --- a/front_end/src/components/comment_feed/comment_editor.tsx +++ b/front_end/src/components/comment_feed/comment_editor.tsx @@ -210,21 +210,6 @@ const CommentEditor: FC = ({ )} - {/* TODO: this box can only be shown in create, not edit mode */} - {shouldIncludeForecast && ( - { - setHasIncludedForecast(checked); - }} - label={t("includeMyForecast")} - className="p-1 text-sm" - /> - )} - {/* TODO: display in preview mode only */} - {/*comment.included_forecast && ( - - )*/}
= ({ /> )}
+ {shouldIncludeForecast && ( + { + setHasIncludedForecast(checked); + }} + label={t("includeMyForecast")} + className="ml-auto mt-2 w-fit text-sm" + /> + )} {(isReplying || hasInteracted) && (
{!isReplying && isPrivateFeed && ( diff --git a/front_end/src/components/comment_feed/included_forecast.tsx b/front_end/src/components/comment_feed/included_forecast.tsx index f64550dc83..0974ee63c7 100644 --- a/front_end/src/components/comment_feed/included_forecast.tsx +++ b/front_end/src/components/comment_feed/included_forecast.tsx @@ -8,11 +8,12 @@ import ChoiceIcon from "@/components/choice_icon"; import Button from "@/components/ui/button"; import { MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors"; import { ForecastType } from "@/types/comment"; -import { QuestionType } from "@/types/question"; +import { Question, QuestionType, UserForecast } from "@/types/question"; import cn from "@/utils/core/cn"; import { formatDate } from "@/utils/formatters/date"; import { abbreviatedNumber } from "@/utils/formatters/number"; import { getQuestionDateFormatString } from "@/utils/formatters/prediction"; +import { scaleInternalLocation } from "@/utils/math"; import { formatValueUnit } from "@/utils/questions/units"; type Props = { @@ -24,55 +25,63 @@ type ForecastValueProps = { forecast: ForecastType; }; -const ForecastValue: FC = ({ forecast }) => { - const t = useTranslations(); - const [showAll, setShowAll] = useState(false); +export function userForecastToForecastType( + userForecast: UserForecast, + question: Question +): ForecastType { + const scaling = question.scaling; + const questionType = question.type; - if (forecast.question_type == QuestionType.Binary) { - return ( -
- {`${Math.round(forecast.probability_yes * 1000) / 10}%`} -
- ); + let quartiles: [number, number, number] = [0, 0, 0]; + if ( + questionType !== QuestionType.Binary && + questionType !== QuestionType.MultipleChoice + ) { + const q1 = userForecast.interval_lower_bounds?.[0] ?? 0.25; + const q2 = userForecast.centers?.[0] ?? 0.5; + const q3 = userForecast.interval_upper_bounds?.[0] ?? 0.75; + quartiles = [ + scaleInternalLocation(q1, scaling), + scaleInternalLocation(q2, scaling), + scaleInternalLocation(q3, scaling), + ]; } - if (forecast.question_type == QuestionType.MultipleChoice) { + + return { + start_time: new Date(userForecast.start_time * 1000), + probability_yes: userForecast.forecast_values[1] ?? 0, + probability_yes_per_category: userForecast.forecast_values, + options: question.options ?? [], + continuous_cdf: userForecast.forecast_values, + quartiles, + scaling, + question_type: questionType, + question_unit: question.unit, + }; +} + +export function formatForecastValueText(forecast: ForecastType): string { + if (forecast.question_type === QuestionType.Binary) { + return `${Math.round(forecast.probability_yes * 1000) / 10}%`; + } + + if (forecast.question_type === QuestionType.MultipleChoice) { const choices = forecast.probability_yes_per_category .map((probability, index) => ({ - probability: probability, + probability, name: forecast.options[index], - color: MULTIPLE_CHOICE_COLOR_SCALE[index], })) .sort((a, b) => b.probability - a.probability); - return ( -
    - {choices.map((choice, index) => ( -
  1. 1, - })} - key={index} - > - {/* TODO: why does this generate a slightly different color than in ForecastChoiceOption ? */} - - {`${choice.name}: ${Math.round(choice.probability * 1000) / 10}%`} -
  2. - ))} - -
- ); + const top = choices[0]; + if (top) { + return `${top.name}: ${Math.round(top.probability * 1000) / 10}%`; + } + return ""; } - // continuous questions get customized formatting - if (forecast.quartiles.length !== 3) return null; + if (forecast.quartiles.length !== 3) return ""; const { range_min, range_max } = forecast.scaling; - if (isNil(range_min) || isNil(range_max)) return null; + if (isNil(range_min) || isNil(range_max)) return ""; const q1 = forecast.quartiles[0] <= range_min @@ -125,19 +134,20 @@ const ForecastValue: FC = ({ forecast }) => { format(new Date(forecast.quartiles[2] * 1000), dateFormatString), format(new Date(range_max * 1000), dateFormatString), ]; - let text: string = ""; + if (q1 === "below" && q2 === "below" && q3 === "below") { - text = + return ( probBelow + "% " + (forecast.question_type === QuestionType.Numeric || forecast.question_type === QuestionType.Discrete ? "below " : "before ") + - valueText[0]; + valueText[0] + ); } if (q1 === "below" && q2 === "below" && q3 === "inRange") { - text = + return ( probBelow + "% " + (forecast.question_type === QuestionType.Numeric || @@ -147,10 +157,11 @@ const ForecastValue: FC = ({ forecast }) => { valueText[0] + " (upper 75%=" + valueText[3] + - ")"; + ")" + ); } if (q1 === "below" && q2 === "inRange" && q3 === "inRange") { - text = + return ( valueText[2] + " (" + probBelow + @@ -160,13 +171,14 @@ const ForecastValue: FC = ({ forecast }) => { ? "below " : "before ") + valueText[0] + - ")"; + ")" + ); } if (q1 === "inRange" && q2 === "inRange" && q3 === "inRange") { - text = valueText[2] + " (" + valueText[1] + " - " + valueText[3] + ")"; + return valueText[2] + " (" + valueText[1] + " - " + valueText[3] + ")"; } if (q1 === "inRange" && q2 === "inRange" && q3 === "above") { - text = + return ( valueText[2] + " (" + probAbove + @@ -176,10 +188,11 @@ const ForecastValue: FC = ({ forecast }) => { ? "above " : "after ") + valueText[4] + - ")"; + ")" + ); } if (q1 === "inRange" && q2 === "above" && q3 === "above") { - text = + return ( probAbove + "% " + (forecast.question_type === QuestionType.Numeric || @@ -189,24 +202,67 @@ const ForecastValue: FC = ({ forecast }) => { valueText[4] + " (lower 25%=" + valueText[1] + - ")"; + ")" + ); } if (q1 === "above" && q2 === "above" && q3 === "above") { - text = + return ( probAbove + "% " + (forecast.question_type === QuestionType.Numeric || forecast.question_type === QuestionType.Discrete ? "above " : "after ") + - valueText[4]; + valueText[4] + ); } if (q1 === "below" && q2 === "inRange" && q3 === "above") { - text = valueText[2] + " (" + valueText[1] + " - " + valueText[3] + ")"; + return valueText[2] + " (" + valueText[1] + " - " + valueText[3] + ")"; } + return ""; +} + +const ForecastValue: FC = ({ forecast }) => { + const t = useTranslations(); + const [showAll, setShowAll] = useState(false); + + if (forecast.question_type === QuestionType.MultipleChoice) { + const choices = forecast.probability_yes_per_category + .map((probability, index) => ({ + probability: probability, + name: forecast.options[index], + color: MULTIPLE_CHOICE_COLOR_SCALE[index], + })) + .sort((a, b) => b.probability - a.probability); + return ( +
    + {choices.map((choice, index) => ( +
  1. 1, + })} + key={index} + > + {/* TODO: why does this generate a slightly different color than in ForecastChoiceOption ? */} + + {`${choice.name}: ${Math.round(choice.probability * 1000) / 10}%`} +
  2. + ))} + +
+ ); + } + return (
- {`${text}`} + {formatForecastValueText(forecast)}
); }; diff --git a/front_end/src/services/api/comments/comments.server.ts b/front_end/src/services/api/comments/comments.server.ts index 0a950aacc1..7d63af6bb2 100644 --- a/front_end/src/services/api/comments/comments.server.ts +++ b/front_end/src/services/api/comments/comments.server.ts @@ -17,8 +17,8 @@ class ServerCommentsApiClass extends CommentsApi { return await this.post(`/comments/${id}/delete/`, null); } - async editComment(commentData: EditCommentParams): Promise { - return await this.post( + async editComment(commentData: EditCommentParams): Promise { + return await this.post( `/comments/${commentData.id}/edit/`, commentData ); diff --git a/front_end/src/services/api/comments/comments.shared.ts b/front_end/src/services/api/comments/comments.shared.ts index 2663230e14..4a5884cf3e 100644 --- a/front_end/src/services/api/comments/comments.shared.ts +++ b/front_end/src/services/api/comments/comments.shared.ts @@ -34,6 +34,7 @@ export type EditCommentParams = { id: number; text: string; author: number; + include_forecast?: boolean; }; export type VoteParams = { diff --git a/posts/services/common.py b/posts/services/common.py index 349924804c..d306e6346d 100644 --- a/posts/services/common.py +++ b/posts/services/common.py @@ -4,7 +4,6 @@ from django.conf import settings from django.db import transaction -from django.db.models import Q from django.db.utils import IntegrityError from django.utils import timezone from django.utils.translation import activate @@ -339,10 +338,7 @@ def compute_sorting_divergence(post: Post) -> dict[int, float]: if cp is None: continue - active_forecasts = question.user_forecasts.filter( - Q(end_time__isnull=True) | Q(end_time__gt=now), - start_time__lte=now, - ) + active_forecasts = question.user_forecasts.filter_active_at(now) for forecast in active_forecasts: difference = prediction_difference_for_sorting( forecast.get_prediction_values(), diff --git a/questions/models.py b/questions/models.py index b81b0a92c2..ad27cb57fe 100644 --- a/questions/models.py +++ b/questions/models.py @@ -474,6 +474,11 @@ def filter_within_question_period(self): ), ) + def filter_active_at(self, timestamp: datetime): + return self.filter(start_time__lte=timestamp).filter( + Q(end_time__gt=timestamp) | Q(end_time__isnull=True) + ) + def active(self): """ Returns active forecasts. diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index cf13ee8770..18293193e5 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -1,6 +1,10 @@ +from datetime import timedelta + import pytest # noqa from django.urls import reverse +from django.utils import timezone from django_dynamic_fixture import G +from freezegun import freeze_time from comments.models import ( Comment, @@ -674,3 +678,164 @@ def test_empty_comment(self, user1, post, user1_client): assert not KeyFactor.objects.filter(pk=kf2.pk).exists() # Last KF deleted - drop comment assert not Comment.objects.filter(pk=comment.pk).exists() + + +class TestCommentEdit: + def test_comment_edit_include_forecast(self, user1, user1_client, question_binary): + post = factory_post(author=user1, question=question_binary) + question = post.question + now = timezone.now() + + # 0. Forecast created and closed before comment creation + t_forecast_expired_start = now - timedelta(hours=4) + t_forecast_expired_end = now - timedelta(hours=3) + + with freeze_time(t_forecast_expired_start): + forecast_expired = create_forecast( + question=question, + user=user1, + probability_yes=0.2, + ) + + forecast_expired.end_time = t_forecast_expired_end + forecast_expired.save() + + # 1. Forecast active at comment creation + t_forecast_1 = now - timedelta(hours=2) + + with freeze_time(t_forecast_1): + forecast_1 = create_forecast( + question=question, + user=user1, + probability_yes=0.5, + ) + + # 2. Comment created later. + t_comment = now - timedelta(hours=1) + + with freeze_time(t_comment): + comment = factory_comment(author=user1, on_post=post) + + # Ensure timestamps are correct + assert forecast_1.start_time == t_forecast_1 + assert comment.created_at == t_comment + assert forecast_1.start_time < comment.created_at + + # 3. New forecast created after comment + t_forecast_2 = now - timedelta(minutes=30) + with freeze_time(t_forecast_2): + create_forecast( + question=question, + user=user1, + probability_yes=0.8, + ) + + # 4. Edit comment to include forecast + url = reverse("comment-edit", kwargs={"pk": comment.pk}) + response = user1_client.post( + url, {"text": "Updated text", "include_forecast": True} + ) + + assert response.status_code == 200 + comment.refresh_from_db() + + # Should attach forecast_1 (active at creation), not forecast_2 (created later) + assert comment.included_forecast == forecast_1 + + # 5. Prevent overwrite if already set + with freeze_time(now): + create_forecast( + question=question, + user=user1, + probability_yes=0.9, + ) + + # Even if we pass include_forecast=True again, it shouldn't change + response = user1_client.post( + url, {"text": "Updated text 2", "include_forecast": True} + ) + assert response.status_code == 200 + comment.refresh_from_db() + assert comment.included_forecast == forecast_1 + + # 6. Test include_forecast=False (should do nothing if already set) + response = user1_client.post( + url, {"text": "Updated text 3", "include_forecast": False} + ) + assert response.status_code == 200 + comment.refresh_from_db() + assert comment.included_forecast == forecast_1 + + # 7. If we manually remove it, include_forecast=False should leave it removed. + comment.included_forecast = None + comment.save() + + response = user1_client.post( + url, {"text": "Updated text 4", "include_forecast": False} + ) + assert response.status_code == 200 + comment.refresh_from_db() + assert comment.included_forecast is None + + # 8. Test attaching when multiple forecasts exist before creation + t_forecast_0 = now - timedelta(hours=3) + with freeze_time(t_forecast_0): + forecast_0 = create_forecast( + question=question, + user=user1, + probability_yes=0.1, + ) + + # Close it before comment creation + forecast_0.end_time = t_forecast_1 + forecast_0.save() + + # So at t_comment, forecast_0 is closed. Forecast_1 is open. + response = user1_client.post( + url, {"text": "Updated text 5", "include_forecast": True} + ) + assert response.status_code == 200 + comment.refresh_from_db() + assert comment.included_forecast == forecast_1 + + def test_comment_edit_include_forecast_closed_question( + self, user1, user1_client, question_binary + ): + """When question is closed, forecast should be taken from closure time, not comment creation time.""" + post = factory_post(author=user1, question=question_binary) + question = post.question + now = timezone.now() + + # Timeline: + t_forecast = now - timedelta(hours=3) + t_close = now - timedelta(hours=2) + + # Create forecast before question closure + with freeze_time(t_forecast): + forecast = create_forecast( + question=question, + user=user1, + probability_yes=0.6, + ) + + # Forecast end_time is after closure (still active at closure) + forecast.end_time = now - timedelta(hours=1) + forecast.save() + + # Close the question + question.actual_close_time = t_close + question.save() + + # Create comment after closure + comment = factory_comment(author=user1, on_post=post) + + # Edit comment to include forecast + url = reverse("comment-edit", kwargs={"pk": comment.pk}) + response = user1_client.post( + url, {"text": "Comment with forecast", "include_forecast": True} + ) + + assert response.status_code == 200 + comment.refresh_from_db() + # Should attach forecast active at closure time + assert comment.included_forecast == forecast diff --git a/utils/the_math/aggregations.py b/utils/the_math/aggregations.py index 1ff6a98c01..87f75ac842 100644 --- a/utils/the_math/aggregations.py +++ b/utils/the_math/aggregations.py @@ -690,9 +690,7 @@ def get_aggregations_at_time( """set include_stats to True if you want to include num_forecasters, q1s, medians, and q3s""" forecasts = ( - question.user_forecasts.filter( - Q(end_time__isnull=True) | Q(end_time__gt=time), start_time__lte=time - ) + question.user_forecasts.filter_active_at(time) .order_by("start_time") .select_related("author") )