Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e43fc21
fix: show comments on medium screens in consumer views (#3848)
SylvainChevalier Dec 2, 2025
9d52f49
Post versions history (#3840)
hlbmtc Dec 2, 2025
0318eff
Fixed Social Signup Last Login Date (#3839)
hlbmtc Dec 2, 2025
241242c
Fixed comments deletion counter (#3849)
hlbmtc Dec 2, 2025
e49ad1b
Save initial version of post after the approval (#3855)
hlbmtc Dec 3, 2025
9e019b5
Resolutions V2 Scores (#3817)
hlbmtc Dec 3, 2025
bc92269
build(deps): bump django from 5.1.14 to 5.1.15 (#3854)
dependabot[bot] Dec 3, 2025
66412a6
fix/3815/reaffirm-single-question-in-group (#3843)
lsabor Dec 3, 2025
0ce414d
fix/3823/my-predictions-feed-tweaks (#3844)
lsabor Dec 3, 2025
95696eb
fix: security vulnerability (#3859)
ncarazon Dec 4, 2025
730d9d7
Add a minimalistic mode for PostCard
elisescu Dec 3, 2025
62888dc
Fixed KeyFactors ValidationError issue (#3863)
hlbmtc Dec 4, 2025
1e61a7c
build(deps): bump h11 from 0.14.0 to 0.16.0 in /screenshot (#3856)
dependabot[bot] Dec 4, 2025
d43c10d
Fixed participation summary label for question tiles (#3866)
hlbmtc Dec 4, 2025
5d305e2
Fix z-index for continuous prediction slider center handle (#3861)
SylvainChevalier Dec 4, 2025
57ba09e
Question Link Votes & Freshness implementation (#3869)
hlbmtc Dec 4, 2025
4df0813
Move Dramatiq rate limit redis db to the same db as queue (#3873)
hlbmtc Dec 4, 2025
545cb4d
mc/3804/backend/updating
lsabor Nov 23, 2025
d96663a
mc/3805/frontend/graphing
lsabor Nov 23, 2025
f05d1b4
Added LRU Cache for ConcurrentRateLimiter Redis Backend (#3874)
hlbmtc Dec 5, 2025
ef96c28
Deprecated old URL schema for coherence links (#3876)
hlbmtc Dec 5, 2025
291e311
Revert "Deprecated old URL schema for coherence links" (#3879)
hlbmtc Dec 5, 2025
c2b1292
Merge branch 'main' of github.com:Metaculus/metaculus into mc/3805/gr…
lsabor Dec 5, 2025
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
11 changes: 11 additions & 0 deletions authentication/social_pipeline.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.conf import settings
from django.contrib.auth import user_logged_in
from rest_framework.exceptions import ValidationError


Expand All @@ -7,3 +8,13 @@ def check_signup_allowed(strategy, details, backend, user=None, *args, **kwargs)
raise ValidationError("Signup is disabled")

return {"user": user, **kwargs}


def send_user_logged_in(strategy, user=None, *args, **kwargs):
"""
Sends the user_logged_in signal when a user logs in via social auth.
"""
if user:
user_logged_in.send(sender=user.__class__, request=strategy.request, user=user)

return {"user": user, **kwargs}
62 changes: 62 additions & 0 deletions coherence/migrations/0004_aggregatecoherencelinkvote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Generated by Django 5.1.14 on 2025-12-04 16:00

import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("coherence", "0003_alter_coherencelink_direction_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="AggregateCoherenceLinkVote",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(
default=django.utils.timezone.now, editable=False
),
),
("edited_at", models.DateTimeField(editable=False, null=True)),
("score", models.SmallIntegerField(choices=[(1, "Up"), (-1, "Down")])),
(
"aggregation",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="votes",
to="coherence.aggregatecoherencelink",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("user_id", "aggregation_id"),
name="uq_aggregate_coherence_link_votes_unique_user",
)
],
},
),
]
41 changes: 41 additions & 0 deletions coherence/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.db import models
from django.db.models import Subquery, OuterRef
from django.db.models.functions import Least, Greatest

from questions.models import Question
Expand Down Expand Up @@ -38,6 +39,21 @@ class Meta:
]


class AggregateCoherenceLinkQuerySet(models.QuerySet):
def annotate_user_vote(self, user: User):
"""
Annotates queryset with the user's vote option
"""

return self.annotate(
user_vote=Subquery(
AggregateCoherenceLinkVote.objects.filter(
user=user, aggregation=OuterRef("pk")
).values("score")[:1]
),
)


class AggregateCoherenceLink(TimeStampedModel):
question1 = models.ForeignKey(
Question, models.CASCADE, related_name="aggregate_coherence_links_as_q1"
Expand All @@ -47,6 +63,11 @@ class AggregateCoherenceLink(TimeStampedModel):
)
type = models.CharField(max_length=16, choices=LinkType.choices)

# Annotated fields
user_vote: int = None

objects = models.Manager.from_queryset(AggregateCoherenceLinkQuerySet)()

class Meta:
constraints = [
models.UniqueConstraint(
Expand All @@ -58,3 +79,23 @@ class Meta:
name="aggregate_different_questions",
),
]


class AggregateCoherenceLinkVote(TimeStampedModel):
class VoteDirection(models.IntegerChoices):
UP = 1
DOWN = -1

user = models.ForeignKey(User, models.CASCADE)
aggregation = models.ForeignKey(
AggregateCoherenceLink, models.CASCADE, related_name="votes"
)
score = models.SmallIntegerField(choices=VoteDirection.choices)

class Meta:
constraints = [
models.UniqueConstraint(
name="uq_aggregate_coherence_link_votes_unique_user",
fields=["user_id", "aggregation_id"],
),
]
55 changes: 52 additions & 3 deletions coherence/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections import Counter
from typing import Iterable

from django.db.models import Q
Expand All @@ -7,7 +8,12 @@

from questions.models import Question
from questions.serializers.common import serialize_question
from .models import CoherenceLink, AggregateCoherenceLink
from users.models import User
from .models import CoherenceLink, AggregateCoherenceLink, AggregateCoherenceLinkVote
from .services import (
get_votes_for_aggregate_coherence_links,
calculate_freshness_aggregate_coherence_link,
)
from .utils import (
get_aggregation_results,
link_to_question_id_pair,
Expand Down Expand Up @@ -38,6 +44,7 @@ class AggregateCoherenceLinkSerializer(serializers.ModelSerializer):
class Meta:
model = AggregateCoherenceLink
fields = [
"id",
"question1_id",
"question2_id",
"type",
Expand Down Expand Up @@ -92,9 +99,13 @@ def serialize_aggregate_coherence_link(
question1: Question,
question2: Question,
matching_links: list[CoherenceLink],
votes: list[AggregateCoherenceLinkVote] = None,
user_vote: int = None,
current_question: Question = None,
):
votes = votes or []

serialized_data = AggregateCoherenceLinkSerializer(link).data
serialized_data["id"] = link.id
if question1:
serialized_data["question1"] = serialize_question(question1)
if question2:
Expand All @@ -104,15 +115,32 @@ def serialize_aggregate_coherence_link(
serialized_data["direction"] = direction
serialized_data["strength"] = strength
serialized_data["rsem"] = rsem if rsem else None

serialized_data["votes"] = serialize_aggregate_coherence_link_vote(
votes, user_vote=user_vote
)

if current_question:
serialized_data["freshness"] = calculate_freshness_aggregate_coherence_link(
current_question, link, votes
)

return serialized_data


def serialize_aggregate_coherence_link_many(links: Iterable[AggregateCoherenceLink]):
def serialize_aggregate_coherence_link_many(
links: Iterable[AggregateCoherenceLink],
current_user: User = None,
current_question: Question = None,
):
ids = [link.pk for link in links]
qs = AggregateCoherenceLink.objects.filter(
pk__in=[c.pk for c in links]
).select_related("question1", "question2")

if current_user:
qs = qs.annotate_user_vote(current_user)

aggregate_links = list(qs.all())
aggregate_links.sort(key=lambda obj: ids.index(obj.id))

Expand All @@ -137,6 +165,9 @@ def serialize_aggregate_coherence_link_many(links: Iterable[AggregateCoherenceLi
key = link_to_question_id_pair(link)
matching_links_by_pair.add(key, link)

# Extract user votes
votes_map = get_votes_for_aggregate_coherence_links(aggregate_links)

return [
serialize_aggregate_coherence_link(
link,
Expand All @@ -145,6 +176,24 @@ def serialize_aggregate_coherence_link_many(links: Iterable[AggregateCoherenceLi
matching_links=matching_links_by_pair.getall(
link_to_question_id_pair(link), default=[]
),
votes=votes_map.get(link.id),
user_vote=link.user_vote,
current_question=current_question,
)
for link in aggregate_links
]


def serialize_aggregate_coherence_link_vote(
vote_scores: list[AggregateCoherenceLinkVote],
user_vote: int = None,
):
pivot_votes = Counter([v.score for v in vote_scores])

return {
"aggregated_data": [
{"score": score, "count": count} for score, count in pivot_votes.items()
],
"user_vote": user_vote,
"count": len(vote_scores),
}
69 changes: 67 additions & 2 deletions coherence/services.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Iterable

from django.db import transaction
from django.db.models import Q
from django.utils import timezone

from coherence.models import (
CoherenceLink,
AggregateCoherenceLink,
LinkType,
AggregateCoherenceLinkVote,
)
from questions.models import Question
from questions.services.forecasts import get_user_last_forecasts_map
from users.models import User
from datetime import datetime


def create_coherence_link(
Expand All @@ -21,7 +26,6 @@ def create_coherence_link(
strength: int = None,
link_type: LinkType = None,
):

with transaction.atomic():
obj = CoherenceLink(
user=user,
Expand Down Expand Up @@ -90,3 +94,64 @@ def get_stale_linked_questions(
if current_question.id != question.id
and (not last_forecast or last_forecast.start_time < question_forecast_time)
]


@transaction.atomic
def aggregate_coherence_link_vote(
aggregation: AggregateCoherenceLink,
user: User,
vote: int = None,
):
# Deleting existing vote for this vote type
aggregation.votes.filter(user=user).delete()

if vote is not None:
aggregation.votes.create(user=user, score=vote)


def get_votes_for_aggregate_coherence_links(
aggregations: Iterable[AggregateCoherenceLink],
) -> dict[int, list[AggregateCoherenceLink]]:
"""
Generates map of user votes for a set of KeyFactors
"""

votes = AggregateCoherenceLinkVote.objects.filter(aggregation__in=aggregations)
votes_map = defaultdict(list)

for vote in votes:
votes_map[vote.aggregation_id].append(vote)

return votes_map


def calculate_freshness_aggregate_coherence_link(
question: Question,
aggregation: AggregateCoherenceLink,
votes: list[AggregateCoherenceLinkVote],
) -> float:
"""
Freshness doesn't decay over time
"""

target_question = (
aggregation.question1
if aggregation.question1 != question
else aggregation.question2
)

# If question resolved > 2w ago -> link does not make sense
if (
target_question.actual_resolve_time
and timezone.now() - target_question.actual_resolve_time > timedelta(days=14)
):
return 0.0

if not votes:
return 0.0

freshness = sum([x.score for x in votes]) + 2 * max(0, 3 - len(votes)) / max(
len(votes), 3
)

return max(0.0, freshness)
Loading