Skip to content
Merged
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
24 changes: 24 additions & 0 deletions comments/migrations/0023_comment_comment_user_private_post_idx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.1.15 on 2025-12-16 19:09

from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("comments", "0022_keyfactor_news"),
("posts", "0027_alter_postusersnapshot_private_note_updated_at"),
("projects", "0021_projectindex_project_index_projectindexpost"),
("questions", "0031_forecast_questions_f_author__d4ea27_idx"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddIndex(
model_name="comment",
index=models.Index(
fields=["author", "is_private", "on_post"],
name="comment_user_private_post_idx",
),
),
]
6 changes: 6 additions & 0 deletions comments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ class Meta:
name="comment_check_pinned_comment_is_root",
)
]
indexes = [
models.Index(
fields=["author", "is_private", "on_post"],
name="comment_user_private_post_idx",
)
]

def __str__(self):
return f"Comment by {self.author.username} on {self.on_post or self.on_project}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ def migrate_private_comments(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("posts", "0025_post_actual_resolve_time"),
("comments", "0022_keyfactor_news"),
]

operations = [
Expand Down
9 changes: 2 additions & 7 deletions scoring/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from projects.permissions import ObjectPermission
from projects.services.common import get_site_main_project
from projects.views import get_projects_qs, get_project_permission_for_user
from questions.models import AggregationMethod
from scoring.constants import LeaderboardScoreTypes
from scoring.models import Leaderboard, LeaderboardEntry, LeaderboardsRanksEntry
from scoring.serializers import (
Expand All @@ -24,7 +23,7 @@
)
from scoring.utils import get_contributions, update_project_leaderboard
from users.models import User
from users.services.profile_stats import serialize_profile
from users.services.profile_stats import serialize_metaculus_stats


@api_view(["GET"])
Expand Down Expand Up @@ -352,13 +351,9 @@ def medal_contributions(
return Response(return_data)


@cache_page(60 * 60 * 24)
@api_view(["GET"])
@permission_classes([AllowAny])
def metaculus_track_record(
request: Request,
):
# TODO: make it "default"
return Response(
serialize_profile(aggregation_method=AggregationMethod.RECENCY_WEIGHTED)
)
return Response(serialize_metaculus_stats())
85 changes: 47 additions & 38 deletions users/services/profile_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
from questions.types import AggregationMethod
from scoring.constants import ScoreTypes
from scoring.models import Score
from users.models import User, UserSpamActivity
from users.serializers import UserPublicSerializer
from users.models import User
from utils.cache import cache_get_or_set


@dataclass(frozen=True)
Expand Down Expand Up @@ -376,57 +376,66 @@ def get_authoring_stats_data(
}


def get_user_profile_data(
user: User,
) -> dict:
return UserPublicSerializer(user).data
def _serialize_user_stats(user: User):
score_qs = Score.objects.filter(
question__related_posts__post__default_project__default_permission__isnull=False,
score_type=ScoreTypes.PEER,
)
score_qs = score_qs.filter(user=user)

scores = generate_question_scores(score_qs)
data = {}

data.update(get_score_scatter_plot_data(scores=scores, user=user))
data.update(get_score_histogram_data(scores=scores, user=user))
data.update(get_calibration_curve_data(user=user))
data.update(get_forecasting_stats_data(scores=scores, user=user))
data.update(get_authoring_stats_data(user))

return data


def serialize_user_stats(user: User):
return cache_get_or_set(
f"serialize_user_stats:{user.id}",
lambda: _serialize_user_stats(user),
# 1h
timeout=3600,
)


def _serialize_metaculus_stats() -> dict:
aggregation_method = AggregationMethod.RECENCY_WEIGHTED

def serialize_profile(
user: User | None = None,
aggregation_method: AggregationMethod | None = None,
score_type: ScoreTypes | None = None,
current_user: User | None = None,
) -> dict:
if (user is None and aggregation_method is None) or (
user is not None and aggregation_method is not None
):
raise ValueError("Either user or aggregation_method must be provided only")
if user is not None and score_type is None:
score_type = ScoreTypes.PEER
if aggregation_method is not None and score_type is None:
score_type = ScoreTypes.BASELINE
# TODO: support archived scores
score_qs = Score.objects.filter(
question__related_posts__post__default_project__default_permission__isnull=False,
score_type=score_type,
score_type=ScoreTypes.BASELINE,
)
if user is not None:
score_qs = score_qs.filter(user=user)
else:
score_qs = score_qs.filter(aggregation_method=aggregation_method)
score_qs = score_qs.filter(aggregation_method=aggregation_method)

scores = generate_question_scores(score_qs)
data = {}
data.update(
get_score_scatter_plot_data(
scores=scores, user=user, aggregation_method=aggregation_method
scores=scores, aggregation_method=aggregation_method
)
)
data.update(
get_score_histogram_data(
scores=scores, user=user, aggregation_method=aggregation_method
)
get_score_histogram_data(scores=scores, aggregation_method=aggregation_method)
)
data.update(get_calibration_curve_data(user, aggregation_method))
data.update(get_calibration_curve_data(aggregation_method=aggregation_method))
data.update(
get_forecasting_stats_data(
scores=scores, user=user, aggregation_method=aggregation_method
)
get_forecasting_stats_data(scores=scores, aggregation_method=aggregation_method)
)
if user is not None:
data.update(get_user_profile_data(user))
data.update(get_authoring_stats_data(user))
if current_user is not None and current_user.is_staff:
data.update({"spam_count": UserSpamActivity.objects.filter(user=user).count()})

return data


def serialize_metaculus_stats():
return cache_get_or_set(
"serialize_metaculus_stats",
lambda: _serialize_metaculus_stats(),
# 24h
timeout=60 * 60 * 24,
)
24 changes: 18 additions & 6 deletions users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from django.contrib.auth.password_validation import validate_password
from django.utils import timezone
from django.views.decorators.cache import cache_page
from rest_framework import serializers, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.exceptions import ValidationError
Expand All @@ -12,7 +11,7 @@
from rest_framework.request import Request
from rest_framework.response import Response

from users.models import User
from users.models import User, UserSpamActivity
from users.serializers import (
UserPrivateSerializer,
UserPublicSerializer,
Expand All @@ -32,7 +31,7 @@
)
from utils.paginator import LimitOffsetPagination
from utils.tasks import email_user_their_data_task
from .services.profile_stats import serialize_profile
from .services.profile_stats import serialize_user_stats
from .services.spam_detection import (
check_profile_update_for_spam,
send_deactivation_email,
Expand All @@ -59,16 +58,29 @@ def current_user_api_view(request):
return Response(UserPrivateSerializer(request.user).data)


@cache_page(60 * 60)
@api_view(["GET"])
@permission_classes([AllowAny])
def user_profile_api_view(request, pk: int):
current_user = request.user

qs = User.objects.all()
if not request.user.is_staff:
if not current_user.is_staff:
qs = qs.filter(is_active=True, is_spam=False)

user = get_object_or_404(qs, pk=pk)

return Response(serialize_profile(user, current_user=request.user))
# Basic profile data
profile = UserPublicSerializer(user).data

if current_user and current_user.is_staff:
profile.update(
{"spam_count": UserSpamActivity.objects.filter(user=user).count()}
)

# Performing slow but cached profile request
profile.update(serialize_user_stats(user))

return Response(profile)


@api_view(["GET"])
Expand Down